commit 5ee2041769a3c6a7630ac663195edf30e555ff2d Author: soraefir Date: Sat May 23 15:09:28 2026 +0200 init diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..82c5eaa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,68 @@ +#file: noinspection SpellCheckingInspection + +name: CI-Android APK + +env: + main_project_module: app + playstore_name: Beans + +on: + push: + branches: [ main ] + tags: + - '**' + pull_request: + branches: [ main ] + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: set up secrets + run: | + echo "${{ secrets.RELEASE_KEYSTORE }}" > keystore.asc + echo "${{ secrets.RELEASE_KEY}}" > key.asc + gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties + gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks + + - name: create and checkout branch + if: github.event_name == 'pull_request' + env: + BRANCH: ${{ github.head_ref }} + run: git checkout -B "$BRANCH" + + - name: set up JDK + uses: actions/setup-java@v5 + with: + java-version: 21 + distribution: "temurin" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build APK + run: | + VERSION_CODE=$(git rev-list --count HEAD) + ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE + + - name: Release + uses: softprops/action-gh-release@v3 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + app/build/outputs/apk/signedRelease/app-signedRelease.apk + prerelease: | + if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+\.[0-9]+[a-z]+$ ]]; then + echo "false" + else + echo "true" + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..509b477 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.gradle/ +local.properties +.DS_Store +build/ +captures/ +.idea/ +.kotlin/ +*.iml +.*.swp +projectFilesBackup/ +app/debug/ +app/lint/ +app/release/ +/gradle/verification-keyring.gpg +/crowdin.* +app/build \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cecc1d --- /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. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + 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: + + {project} Copyright (C) {year} {fullname} + 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/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..73dba86 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,12 @@ +# Cowspent Privacy Policy + +Cowspent does not collect any personal data. +This is a Free/Libre software so you can verify no data is collected +by reading the source code or asking someone to do it. + +Members and bills are synchronized with +the Cospend server of your choice. + +CAMERA permission is used to read QR-codes. + +GET\_ACCOUNTS permission is used by Single-Sign-On library to access accounts from Nextcloud Files app. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d92f56 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ + +
+

Cowspent

+ Logo + +

+Shared budget manager able to sync with [Nextcloud Cospend](https://github.com/julien-nc/cospend-nc). + +This work is based upon [MoneyBuster](https://gitlab.com/eneiluj/moneybuster). +Which is originally a fork of [PhoneTrack-Android](https://gitlab.com/eneiluj/phonetrack-android/). +Which is itself a fork of [Nextcloud Notes for Android](https://github.com/stefan-niedermann/nextcloud-notes). +Many thanks to their developers :heart: ! + + + +

+ +ko-fi +
+Built for Android +Built with love +
+ +Build Status + +
+ +## 🌄 Screenshots + +
+ + + + + + +
LauncherViewEdit
+
+ +## ⭐ Features + +- manage projects (add/remove/create/delete/edit) +- manage members (add/remove/edit) +- manage bills (add/remove/edit) +- search bills (by ~~payer~~, name, ~~amount~~, ~~date~~) - **still WIP** +- project statistics (Table, Graph and Sankey) with sharing +- project settlement plan with sharing +- dark theme and customizable main app color +- share/import projects with link/QRCode + +Extra Features: + +- Custom Split of bills +- Support for Cospend archived-status (hide archived projects by default) +- Modern UI and code with Kotlin and Jetpack-Compose + + +## 📳 Installation + + + +## ⚙️ Permissions + +- `CAMERA`: recommended for scanning barcodes from camera +- `INTERNET` and `ACCESS_NETWORK_STATE`: necessary for synchronization with Cospend/IHateMoney servers +- `GET_ACCOUNTS` necessary to log in with the Nextcloud account for Cospend + +## 📝 Contribute + +Cowspent is a user-driven project. We welcome any contribution, big or small. + +- **🖥️ Development:** Fix bugs, implement features, or research issues. Open a PR for review. +- **🍥 Design:** Improve interfaces, including accessibility and usability. +- **📂 Issue Reporting:** Report bugs and edge cases with relevant info. +- **🌍 Localization:** Translate if it doesn't support your language. + +## ✏️ Acknowledgements + +Thanks to all contributors, the developers of our dependencies, and our users. + +## 📝 License + +``` +Copyright (C) 2026 Helcel + +Cowspent Logo made with OpenMoji Asset under CC BY-SA 4.0 + +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 . +``` diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..9a1a5aa --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,111 @@ +plugins { + id 'com.android.application' version '9.2.1' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21' + id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21' +} + +android { + namespace = 'net.helcel.cowspent' + compileSdk = 37 + + defaultConfig { + buildConfigField("String", "APP_NAME", "\"Cowspent\"") + manifestPlaceholders["APP_NAME"] = "Cowspent" + applicationId "net.helcel.cowspent" + minSdk = 26 + targetSdk = 37 + versionName "1.0" + versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 + } + + signingConfigs { + register("release") { + try { + def keystorePropertiesFile = rootProject.file("app/keystore.properties") + def keystoreProperties = new Properties() + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + } catch (FileNotFoundException e) { + println("File not found: ${e.message}") + } + } + } + + buildTypes { + debug { + debuggable true + initWith(buildTypes.release) + signingConfig = signingConfigs.debug + } + release { + minifyEnabled true + shrinkResources = true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + signedRelease { + minifyEnabled true + shrinkResources = true + initWith(buildTypes.release) + matchingFallbacks = ['release'] + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + coreLibraryDesugaringEnabled = true + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + encoding = 'utf-8' + } + + buildFeatures { + viewBinding = true + buildConfig = true + compose = true + } + + kotlin { + jvmToolchain(21) + } + + lint { + abortOnError = false + disable 'MissingTranslation' + disable 'UsingMaterialAndMaterial3Libraries' + } + + androidResources { + generateLocaleConfig = true + } +} + +dependencies { + implementation 'androidx.compose.foundation:foundation:1.11.2' + implementation 'androidx.compose.runtime:runtime:1.11.2' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + + implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'androidx.work:work-runtime-ktx:2.11.2' + + implementation 'com.google.zxing:core:3.5.4' + implementation 'androidx.camera:camera-camera2:1.6.1' + implementation 'androidx.camera:camera-lifecycle:1.6.1' + implementation 'androidx.camera:camera-view:1.6.1' + implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' + implementation 'com.opencsv:opencsv:5.12.0' + + implementation platform('androidx.compose:compose-bom:2026.05.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.material:material' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.activity:activity-compose:1.13.0' + implementation 'androidx.activity:activity-ktx:1.13.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..67f98d9 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in C:\Users\stnieder\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# 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 *; +#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..67a0b67 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..212c605 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt new file mode 100644 index 0000000..a0606b1 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt @@ -0,0 +1,21 @@ +package net.helcel.cowspent.android.about + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import net.helcel.cowspent.theme.ThemeUtils + +class AboutActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ThemeUtils.CowspentTheme { + AboutScreen( + onBack = { finish() } + ) + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt new file mode 100644 index 0000000..e9ae939 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt @@ -0,0 +1,153 @@ +package net.helcel.cowspent.android.about + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.BuildConfig +import net.helcel.cowspent.R +import net.helcel.cowspent.theme.ThemeUtils + +@Composable +fun AboutScreen( + onBack: () -> Unit +) { + val uriHandler = LocalUriHandler.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.simple_about)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .size(156.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.h4, + fontWeight = FontWeight.Bold + ) + + Text( + text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME), + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(48.dp)) + + AboutItem( + title = stringResource(R.string.about_maintainer_title), + content = stringResource(R.string.about_maintainer) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + AboutItem( + title = stringResource(R.string.about_license_title), + content = stringResource(R.string.about_license) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + val sourceUrl = stringResource(R.string.about_source) + AboutItem( + title = stringResource(R.string.about_source_title), + content = sourceUrl, + onClick = { uriHandler.openUri(sourceUrl) } + ) + } + } +} + +@Composable +fun AboutItem( + title: String, + content: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + var itemModifier = modifier.fillMaxWidth() + if (onClick != null) { + itemModifier = itemModifier.clickable(onClick = onClick) + } + + Column( + modifier = itemModifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.Bold + ) + Text( + text = content, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + color = if (onClick != null) MaterialTheme.colors.primary else MaterialTheme.typography.body1.color, + textDecoration = if (onClick != null) TextDecoration.Underline else null + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AboutScreenPreview() { + ThemeUtils.CowspentTheme { + AboutScreen(onBack = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt new file mode 100644 index 0000000..efe64de --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt @@ -0,0 +1,339 @@ +package net.helcel.cowspent.android.account + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.edit +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import com.nextcloud.android.sso.AccountImporter +import com.nextcloud.android.sso.helper.SingleAccountHelper +import com.nextcloud.android.sso.model.SingleSignOnAccount +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.android.main.MainConstants +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.CospendClientUtil +import net.helcel.cowspent.util.CospendClientUtil.LoginStatus +import java.net.URLDecoder +import java.util.Locale + + +class AccountActivity : AppCompatActivity() { + + private val viewModel: AccountViewModel by viewModels() + + companion object { + private val TAG = AccountActivity::class.java.simpleName + + const val SETTINGS_USE_SSO = "settingsUseSSO" + const val SETTINGS_SSO_URL = "settingsSSOUrl" + const val SETTINGS_SSO_USERNAME = "settingsSSOUsername" + const val SETTINGS_URL = "settingsUrl" + const val SETTINGS_USERNAME = "settingsUsername" + const val SETTINGS_PASSWORD = "settingsPassword" + const val SETTINGS_KEY_ETAG = "sessions_last_etag" + const val SETTINGS_KEY_LAST_MODIFIED = "sessions_last_modified" + const val DEFAULT_SETTINGS = "" + const val CREDENTIALS_CHANGED = 3 + + const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":" + const val WEBDAV_PATH_4_0_AND_LATER = "/remote.php/webdav" + } + + private lateinit var preferences: SharedPreferences + private var oldPassword = "" + private var useWebLogin = true + private var showLoginDialog by mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (viewModel.showWebView) { + viewModel.showWebView = false + } else { + finish() + } + } + }) + + setContent { + ThemeUtils.CowspentTheme { + if (viewModel.showWebView) { + val serverUrl = CospendClientUtil.formatURL(viewModel.serverUrl) + WebLoginScreen( + url = normalizeUrlSuffix(serverUrl) + "index.php/login/flow", + onLoginUrlDetected = { parseAndLoginFromWebView(it) } + ) + } else { + AccountScreen( + viewModel = viewModel, + onBack = { finish() }, + onConnect = { login() }, + onSsoClick = { isChecked -> + if (isChecked) { + showLoginDialog = true + } else { + viewModel.useSso = false + preferences.edit { putBoolean(SETTINGS_USE_SSO, false) } + } + }, + onLogout = { viewModel.logout() } + ) + } + + LoginDialog( + showDialog = showLoginDialog, + onDismissRequest = { showLoginDialog = false }, + onInitiateSsoLogin = { + showLoginDialog = false + try { + AccountImporter.pickNewAccount(this@AccountActivity) + } catch (e: Exception) { + Log.e(TAG, "Failed to initiate SSO login", e) + Toast.makeText(this@AccountActivity, "SSO login failed: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + ) + } + } + + oldPassword = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS) ?: "" + viewModel.validateUrl() + } + + @SuppressLint("SetJavaScriptEnabled") + @Composable + private fun WebLoginScreen(url: String, onLoginUrlDetected: (String) -> Unit) { + var isLoading by remember { mutableStateOf(true) } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + factory = { context -> + WebView(context).apply { + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + + settings.apply { + allowFileAccess = false + javaScriptEnabled = true + domStorageEnabled = true + userAgentString = getWebLoginUserAgent(context) + } + webViewClient = object : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + if (url?.startsWith("nc://login/") == true) { + onLoginUrlDetected(url) + return true + } + return false + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url?.toString() + if (url?.startsWith("nc://login/") == true) { + onLoginUrlDetected(url) + return true + } + return false + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + isLoading = false + } + } + + val headers = HashMap() + headers["OCS-APIREQUEST"] = "true" + loadUrl(url, headers) + } + }, + modifier = Modifier.fillMaxSize() + ) + + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + try { + AccountImporter.onActivityResult(requestCode, resultCode, data, this + ) { ssoAccount -> + lifecycleScope.launch { + SingleAccountHelper.commitCurrentAccount(applicationContext, ssoAccount.name) + + preferences.edit { + putBoolean(SETTINGS_USE_SSO, true) + putString(SETTINGS_SSO_URL, ssoAccount.url) + putString(SETTINGS_SSO_USERNAME, ssoAccount.userId) + } + + viewModel.useSso = true + viewModel.serverUrl = ssoAccount.url + viewModel.username = ssoAccount.userId + + val resultData = Intent() + resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) + setResult(RESULT_OK, resultData) + finish() + } + } + } catch (e: Exception) { + Log.e(TAG, "SSO account selection failed", e) + } + } + + private fun legacyLogin() { + val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim()) + val username = viewModel.username + var password = viewModel.password + + if (password.isEmpty()) { + password = oldPassword + } + + performLogin(url, username, password) + } + + private fun login() { + if (useWebLogin) { + viewModel.showWebView = true + } else { + legacyLogin() + } + } + + private fun getWebLoginUserAgent(context: Context): String { + val defaultUA = try { + android.webkit.WebSettings.getDefaultUserAgent(context) + } catch (_: Exception) { + Build.MANUFACTURER + " " + Build.MODEL + } + return "$defaultUA Cowspent/Android" + } + + private fun parseAndLoginFromWebView(dataString: String) { + try { + val loginUrlInfo = parseLoginDataUrl(dataString) + val url = normalizeUrlSuffix(loginUrlInfo.serverAddress) + performLogin(url, loginUrlInfo.username, loginUrlInfo.password) + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Invalid login URL", e) + } + } + + private fun parseLoginDataUrl(dataString: String): LoginUrlInfo { + val prefix = "nc://login/" + if (dataString.length < prefix.length) { + throw IllegalArgumentException("Invalid login URL detected") + } + val loginUrlInfo = LoginUrlInfo() + val data = dataString.substring(prefix.length) + val values = data.split("&") + + if (values.size !in 1..3) { + throw IllegalArgumentException("Illegal number of login URL elements detected: ${values.size}") + } + + for (value in values) { + when { + value.startsWith("user$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR") -> { + loginUrlInfo.username = URLDecoder.decode(value.substring(("user$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length), "UTF-8") + } + value.startsWith("password$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR") -> { + loginUrlInfo.password = URLDecoder.decode(value.substring(("password$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length), "UTF-8") + } + value.startsWith("server$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR") -> { + loginUrlInfo.serverAddress = URLDecoder.decode(value.substring(("server$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length), "UTF-8") + } + else -> throw IllegalArgumentException("Illegal magic login URL element detected: $value") + } + } + return loginUrlInfo + } + + private fun normalizeUrlSuffix(url: String): String { + return when { + url.lowercase(Locale.ROOT).endsWith(WEBDAV_PATH_4_0_AND_LATER) -> { + url.substring(0, url.length - WEBDAV_PATH_4_0_AND_LATER.length) + } + !url.endsWith("/") -> "$url/" + else -> url + } + } + + + private fun performLogin(url: String, username: String, password: String) { + viewModel.isSubmitting = true + viewModel.showWebView = false + + lifecycleScope.launch { + val status = withContext(Dispatchers.IO) { + CospendClientUtil.isValidLogin(url, username, password) + } + + if (status == LoginStatus.OK) { + preferences.edit { + putString(SETTINGS_URL, url) + putString(SETTINGS_USERNAME, username) + putString(SETTINGS_PASSWORD, password) + remove(SETTINGS_KEY_ETAG) + remove(SETTINGS_KEY_LAST_MODIFIED) + } + + val data = Intent() + data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) + setResult(RESULT_OK, data) + finish() + } else { + Log.e("Cowspent", "invalid login") + viewModel.isSubmitting = false + Toast.makeText(applicationContext, getString(R.string.error_invalid_login, getString(status.str)), Toast.LENGTH_LONG).show() + } + } + } + + + class LoginUrlInfo { + var serverAddress: String = "" + var username: String = "" + var password: String = "" + } + +} diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt new file mode 100644 index 0000000..9c7c213 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt @@ -0,0 +1,266 @@ +@file:Suppress("SameParameterValue", "SameParameterValue", "SameParameterValue", + "SameParameterValue", "SameParameterValue", "SameParameterValue" +) + +package net.helcel.cowspent.android.account + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.R +import net.helcel.cowspent.theme.ThemeUtils + +@Composable +fun AccountScreen( + viewModel: AccountViewModel, + onBack: () -> Unit, + onConnect: () -> Unit, + onSsoClick: (Boolean) -> Unit, + onLogout: () -> Unit +) { + AccountScreenContent( + isLoggedIn = viewModel.isLoggedIn, + isValidatingLogin = viewModel.isValidatingLogin, + useSso = viewModel.useSso, + serverUrl = viewModel.serverUrl, + username = viewModel.username, + password = viewModel.password, + isUrlValid = viewModel.isUrlValid, + showUrlWarning = viewModel.showUrlWarning, + isSubmitting = viewModel.isSubmitting, + isFormValid = viewModel.isFormValid, + onServerUrlChange = { + viewModel.serverUrl = it + viewModel.validateUrl() + }, + onUsernameChange = { viewModel.username = it }, + onPasswordChange = { viewModel.password = it }, + onBack = onBack, + onConnect = onConnect, + onSsoClick = onSsoClick, + onLogout = onLogout + ) +} + +@Composable +fun AccountScreenContent( + isLoggedIn: Boolean, + isValidatingLogin: Boolean, + useSso: Boolean, + serverUrl: String, + username: String, + password: String, + isUrlValid: Boolean, + showUrlWarning: Boolean, + isSubmitting: Boolean, + isFormValid: Boolean, + onServerUrlChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onBack: () -> Unit, + onConnect: () -> Unit, + onSsoClick: (Boolean) -> Unit, + onLogout: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_server_settings)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + .fillMaxSize() + ) { + if (isValidatingLogin) { + Box(modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (isLoggedIn) { + Text( + text = stringResource(R.string.account_logged_in_as, username), + style = MaterialTheme.typography.h6 + ) + Text( + text = serverUrl, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onLogout, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.account_logout)) + } + Spacer(modifier = Modifier.height(24.dp)) + } + + if (!isValidatingLogin) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Text(stringResource(R.string.use_sso_toggle), modifier = Modifier.weight(1f)) + Switch( + checked = useSso, + onCheckedChange = { onSsoClick(it) }, + colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + uncheckedTrackColor = MaterialTheme.colors.onSurface.copy(alpha = 0.3f) + ) + ) + } + + if (!useSso) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = serverUrl, + onValueChange = onServerUrlChange, + label = { Text(stringResource(R.string.settings_url)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }, + trailingIcon = { + if (isUrlValid) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.Green + ) + } + }, + singleLine = true + ) + + if (showUrlWarning) { + Text( + stringResource(R.string.settings_url_warn_http), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = username, + onValueChange = onUsernameChange, + label = { Text(stringResource(R.string.settings_username)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + @Suppress("DEPRECATION") + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.settings_password)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onConnect, + modifier = Modifier.fillMaxWidth(), + enabled = isFormValid && !isSubmitting + ) { + if (isSubmitting) { + CustomCircularProgressIndicator(size = 24.dp, color = Color.White) + } else { + Text(stringResource(R.string.settings_submit)) + } + } + } + } + } + } +} + +@Composable +fun CustomCircularProgressIndicator(size: Dp, color: Color) { + CircularProgressIndicator( + modifier = Modifier.size(size), + color = color, + strokeWidth = 2.dp + ) +} + +@Preview(showBackground = true) +@Composable +fun AccountScreenPreview() { + ThemeUtils.CowspentTheme { + AccountScreenContent( + isLoggedIn = false, + isValidatingLogin = false, + useSso = false, + serverUrl = "https://nextcloud.example.com", + username = "user", + password = "", + isUrlValid = true, + showUrlWarning = false, + isSubmitting = false, + isFormValid = true, + onServerUrlChange = {}, + onUsernameChange = {}, + onPasswordChange = {}, + onBack = {}, + onConnect = {}, + onSsoClick = {}, + onLogout = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt new file mode 100644 index 0000000..8966d0c --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt @@ -0,0 +1,122 @@ +package net.helcel.cowspent.android.account + +import android.app.Application +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.util.CospendClientUtil + +class AccountViewModel(application: Application) : AndroidViewModel(application) { + private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) + + var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) + var serverUrl by mutableStateOf( + if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + preferences.getString(AccountActivity.SETTINGS_SSO_URL, "") ?: "" + } else { + preferences.getString(AccountActivity.SETTINGS_URL, "") ?: "" + } + ) + var username by mutableStateOf( + if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + preferences.getString(AccountActivity.SETTINGS_SSO_USERNAME, "") ?: "" + } else { + preferences.getString(AccountActivity.SETTINGS_USERNAME, "") ?: "" + } + ) + var password by mutableStateOf("") + + var isUrlValid by mutableStateOf(false) + var isSubmitting by mutableStateOf(false) + var showUrlWarning by mutableStateOf(false) + var showWebView by mutableStateOf(false) + + var isLoggedIn by mutableStateOf(false) + private set + + var isValidatingLogin by mutableStateOf(false) + private set + + init { + checkLoginStatus() + } + + private fun checkLoginStatus() { + val url = if (useSso) { + preferences.getString(AccountActivity.SETTINGS_SSO_URL, "") + } else { + preferences.getString(AccountActivity.SETTINGS_URL, "") + } + val username = if (useSso) { + preferences.getString(AccountActivity.SETTINGS_SSO_USERNAME, "") + } else { + preferences.getString(AccountActivity.SETTINGS_USERNAME, "") + } + val password = if (useSso) { + "" + } else { + preferences.getString(AccountActivity.SETTINGS_PASSWORD, "") + } + + if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) { + viewModelScope.launch { + isValidatingLogin = true + isLoggedIn = withContext(Dispatchers.IO) { + if (useSso) { + true + } else { + !password.isNullOrEmpty() && + CospendClientUtil.isValidLogin( + url, + username, + password + ) == CospendClientUtil.LoginStatus.OK + } + } + isValidatingLogin = false + } + } + } + + fun logout() { + preferences.edit { + remove(AccountActivity.SETTINGS_USE_SSO) + remove(AccountActivity.SETTINGS_SSO_URL) + remove(AccountActivity.SETTINGS_SSO_USERNAME) + remove(AccountActivity.SETTINGS_URL) + remove(AccountActivity.SETTINGS_USERNAME) + remove(AccountActivity.SETTINGS_PASSWORD) + remove(AccountActivity.SETTINGS_KEY_ETAG) + remove(AccountActivity.SETTINGS_KEY_LAST_MODIFIED) + } + + useSso = false + serverUrl = "" + username = "" + password = "" + isLoggedIn = false + } + + fun validateUrl() { + val formattedUrl = CospendClientUtil.formatURL(serverUrl) + showUrlWarning = CospendClientUtil.isHttp(formattedUrl) && !useSso + + viewModelScope.launch { + val valid = withContext(Dispatchers.IO) { + CospendClientUtil.isValidURL(formattedUrl) + } + isUrlValid = valid + } + } + + val isFormValid: Boolean + get() = useSso || (isUrlValid && username.isNotEmpty()) +} diff --git a/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt b/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt new file mode 100644 index 0000000..c630148 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt @@ -0,0 +1,117 @@ +package net.helcel.cowspent.android.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun LoginDialog( + showDialog: Boolean, + onDismissRequest: () -> Unit, + onInitiateSsoLogin: () -> Unit, + errorMessage: String? = null +) { + if (showDialog) { + AlertDialog( + onDismissRequest = onDismissRequest, + shape = MaterialTheme.shapes.large, + title = { Text("Login") }, + text = { + if (errorMessage != null) { + Text(text = errorMessage) + } else { + Text(text = "Please choose a login method.") + } + }, + confirmButton = { + TextButton(onClick = onInitiateSsoLogin) { + Text("Login with Nextcloud SSO") + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text("Cancel") + } + }, + modifier = Modifier + ) + } +} + +@Composable +fun LoginDialogContent( + onInitiateSsoLogin: () -> Unit, + onDismissRequest: () -> Unit, + errorMessage: String? = null +) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = "Login", + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (errorMessage != null) { + Text(text = errorMessage) + } else { + Text(text = "Please choose a login method.") + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismissRequest) { + Text("Cancel") + } + TextButton(onClick = onInitiateSsoLogin) { + Text("Login with Nextcloud SSO") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun LoginDialogPreview() { + MaterialTheme { + LoginDialogContent( + onDismissRequest = {}, + onInitiateSsoLogin = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun LoginDialogErrorPreview() { + MaterialTheme { + LoginDialogContent( + onDismissRequest = {}, + onInitiateSsoLogin = {}, + errorMessage = "Invalid credentials. Please try again." + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt new file mode 100644 index 0000000..4681bfc --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt @@ -0,0 +1,462 @@ +package net.helcel.cowspent.android.bill_edit + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.remember +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.QrCodeScannerActivity +import net.helcel.cowspent.android.helper.showToast +import net.helcel.cowspent.android.main.MainConstants +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBBillOwer +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.BillParser +import net.helcel.cowspent.util.CategoryUtils +import java.text.ParseException +import java.time.ZoneId +import java.util.Calendar + +class EditBillActivity : AppCompatActivity() { + + private val viewModel: EditBillViewModel by viewModels() + private lateinit var db: CowspentSQLiteOpenHelper + private lateinit var bill: DBBill + private var projectType: ProjectType = ProjectType.LOCAL + private val calendar = Calendar.getInstance() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + db = CowspentSQLiteOpenHelper.getInstance(this) + + lifecycleScope.launch { + initBill() + + setContent { + ThemeUtils.CowspentTheme { + + val categories = remember { + val syncedCategories = db.getCategories(bill.projectId) + val defaultCategories = CategoryUtils.getDefaultCategories(this@EditBillActivity, bill.projectId) + val hardcoded = if (projectType == ProjectType.LOCAL) { + defaultCategories + } else { + listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) + } + syncedCategories + hardcoded + } + val paymentModes = remember { + val syncedPaymentModes = db.getPaymentModes(bill.projectId) + val defaultPaymentModes = CategoryUtils.getDefaultPaymentModes(this@EditBillActivity, bill.projectId) + if (projectType == ProjectType.LOCAL) { + syncedPaymentModes + defaultPaymentModes + } else { + syncedPaymentModes.ifEmpty { defaultPaymentModes } + } + } + + EditBillScreen( + viewModel = viewModel, + categories = categories, + paymentModes = paymentModes, + onSave = { saveBillAsked() }, + onBack = { onBack() }, + onDateClick = { + DatePickerDialog( + this@EditBillActivity, + { _, year, month, day -> + calendar[Calendar.YEAR] = year + calendar[Calendar.MONTH] = month + calendar[Calendar.DAY_OF_MONTH] = day + viewModel.timestamp = calendar.timeInMillis / 1000 + }, + calendar[Calendar.YEAR], + calendar[Calendar.MONTH], + calendar[Calendar.DAY_OF_MONTH] + ).show() + }, + onTimeClick = { + TimePickerDialog( + this@EditBillActivity, + { _, hour, minute -> + calendar[Calendar.HOUR_OF_DAY] = hour + calendar[Calendar.MINUTE] = minute + viewModel.timestamp = calendar.timeInMillis / 1000 + }, + calendar[Calendar.HOUR_OF_DAY], + calendar[Calendar.MINUTE], + true + ).show() + }, + onScan = { + val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java) + scanQRCodeLauncher.launch(createIntent) + }, + onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null, + accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN + ) + } + } + } + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onBack() + } + }) + } + + private suspend fun initBill() { + val billId = intent.getLongExtra(PARAM_BILL_ID, 0) + val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS) + val projectId = intent.getLongExtra(PARAM_PROJECT_ID, 0) + val projectTypeStr = intent.getStringExtra(PARAM_PROJECT_TYPE) + projectType = if (!projectTypeStr.isNullOrEmpty()) { + ProjectType.getTypeById(projectTypeStr) ?: ProjectType.LOCAL + } else { + ProjectType.LOCAL + } + + withContext(Dispatchers.IO) { + var customSplits: Map? = null + if (groupedBillIds != null && groupedBillIds.isNotEmpty()) { + val sourceBills: List = groupedBillIds.map { db.getBill(it) }.filterNotNull() + if (sourceBills.isNotEmpty()) { + val first = sourceBills[0] + val totalAmount = sourceBills.sumOf { it.amount } + bill = DBBill( + first.id, 0, first.projectId, first.payerId, totalAmount, + first.timestamp, first.what, first.state, first.repeat, + first.paymentMode, first.categoryRemoteId, first.comment, first.paymentModeRemoteId + ) + + val splits = mutableMapOf() + for (b in sourceBills) { + val owersCount = b.billOwers.size + if (owersCount > 0) { + val part = b.amount / owersCount + for (ower in b.billOwers) { + splits[ower.memberId] = (splits[ower.memberId] ?: 0.0) + part + } + } + } + customSplits = splits + } + } else if (billId > 0) { + bill = db.getBill(billId)!! + } else { + val billIdToDuplicate = intent.getLongExtra(PARAM_BILL_ID_TO_DUPLICATE, 0) + val timeNowSeconds = System.currentTimeMillis() / 1000 + if (billIdToDuplicate == 0L) { + bill = DBBill( + 0, 0, projectId, 0, 0.0, timeNowSeconds, + "", DBBill.STATE_ADDED, DBBill.NON_REPEATED, + DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE + ) + } else { + val btd = db.getBill(billIdToDuplicate)!! + bill = DBBill( + 0, 0, projectId, btd.payerId, btd.amount, + timeNowSeconds, btd.what, DBBill.STATE_ADDED, + btd.repeat, btd.paymentMode, btd.categoryRemoteId, + btd.comment, btd.paymentModeRemoteId + ) + val btdOwers = btd.billOwers + val newBillOwers = btdOwers.filter { + val m = db.getMember(it.memberId) + m != null && m.isActivated + } + bill.billOwers = newBillOwers + } + } + calendar.timeInMillis = bill.timestamp * 1000 + val members = db.getMembersOfProject(bill.projectId, null) + val project = db.getProject(bill.projectId) + val currencies = db.getCurrencies(bill.projectId) + withContext(Dispatchers.Main) { + viewModel.currencies = currencies + viewModel.mainCurrencyName = project?.currencyName ?: "" + viewModel.initFromBill(bill, members, customSplits) + } + } + } + + private val scanQRCodeLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK && result.data != null) { + val scannedBill = result.data?.getStringExtra(MainConstants.KEY_QR_CODE) + if (scannedBill != null) { + try { + val austrianBill = BillParser.parseAustrianBillFromQrCode(scannedBill) + calendar.timeInMillis = austrianBill.date.time + viewModel.timestamp = calendar.timeInMillis / 1000 + viewModel.amount = austrianBill.amount.toString() + return@registerForActivityResult + } catch (_: ParseException) { + } + try { + val croatianBill = BillParser.parseCroatianBillFromQrCode(scannedBill) + if (croatianBill.date != null) { + calendar.timeInMillis = + croatianBill.date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + viewModel.timestamp = calendar.timeInMillis / 1000 + } + viewModel.amount = croatianBill.amount.toString() + return@registerForActivityResult + } catch (_: ParseException) { + } + showToast(this, getString(R.string.error_scanning_bill_qr_code)) + } + } + } + + private fun onBack() { + if (!valuesHaveChanged()) { + finish() + return + } + viewModel.showDialog( + title = getString(R.string.save_or_discard_bill_dialog_title), + message = getString(R.string.save_or_discard_bill_dialog_message), + positiveText = getString(R.string.save_or_discard_bill_dialog_save), + onConfirm = { saveBillAsked() }, + negativeText = getString(R.string.save_or_discard_bill_dialog_discard), + onCancel = { finish() } + ) + } + + private fun saveBillAsked() { + val validationError = viewModel.getValidationError( + getString(R.string.error_invalid_bill_what), + getString(R.string.error_invalid_bill_date), + getString(R.string.error_invalid_bill_payerid), + getString(R.string.error_invalid_bill_owers), + getString(R.string.simple_error) + ) + + if (validationError != null) { + showToast(this, validationError) + } else { + lifecycleScope.launch { + val savedBillId = withContext(Dispatchers.IO) { saveBill() } + val data = Intent() + data.putExtra(MainConstants.SAVED_BILL_ID, savedBillId) + setResult(RESULT_OK, data) + finish() + } + } + } + + private fun deleteBillAsked() { + viewModel.showDialog( + title = getString(R.string.confirm_remove_project_dialog_title), + message = bill.what, + positiveText = getString(R.string.action_delete), + onConfirm = { + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS) + if (groupedBillIds != null && groupedBillIds.isNotEmpty()) { + for (id in groupedBillIds) { + db.setBillState(id, DBBill.STATE_DELETED) + } + } else if (bill.id > 0) { + db.setBillState(bill.id, DBBill.STATE_DELETED) + } + val proj = db.getProject(bill.projectId) + if (proj != null) db.syncIfRemote(proj) + } + val data = Intent() + data.putExtra(MainConstants.DELETED_BILL, bill.id) + setResult(RESULT_OK, data) + finish() + } + }, + negativeText = getString(R.string.simple_cancel) + ) + } + + private fun valuesHaveChanged(): Boolean { + val newOwersIds = viewModel.getOwersIds().toSet() + val billOwersIds = bill.billOwersIds.toSet() + val owersChanged = newOwersIds != billOwersIds + + return !(bill.what == viewModel.what && + bill.timestamp == viewModel.timestamp && + bill.amount == viewModel.amountAsDouble && + bill.payerId == viewModel.payerId && + bill.comment == viewModel.comment && + bill.repeat == viewModel.repeat && + bill.categoryRemoteId == viewModel.categoryRemoteId && + bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && + !owersChanged) + } + + private suspend fun saveBill(): Long = withContext(Dispatchers.IO) { + val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS) + val isCustomSplit = viewModel.isCustomSplit + + if (isCustomSplit) { + val splits = viewModel.owersCustomSplit.filter { (id, amount) -> + viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull() + ?: 0.0) > 0 + }.mapValues { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } + + if (splits.isEmpty()) return@withContext 0L + + val splitEntries = splits.entries.toList() + + // Pool of existing bills in this group that we can potentially reuse + val billsToPool = mutableListOf() + if (bill.id != 0L) billsToPool.add(bill.id) + groupedBillIds?.forEach { if (it != bill.id) billsToPool.add(it) } + + val processedBillIds = mutableSetOf() + var firstSavedId = 0L + + for (entry in splitEntries) { + val memberId = entry.key + val amount = entry.value + + // Try to find a bill in the pool that already exists for this exact member + var billToUseId = billsToPool.find { id -> + val b = db.getBill(id) + b?.billOwers?.size == 1 && b.billOwers[0].memberId == memberId + } + + // Fallback: just take any available bill from the pool + if (billToUseId == null) { + billToUseId = billsToPool.firstOrNull() + } + + if (billToUseId != null) { + billsToPool.remove(billToUseId) + processedBillIds.add(billToUseId) + + val existingBill = db.getBill(billToUseId)!! + db.updateBillAndSync( + existingBill, + viewModel.payerId, + amount, + viewModel.timestamp, + viewModel.what, + listOf(memberId), + viewModel.repeat, + existingBill.paymentMode, + viewModel.paymentModeRemoteId, + viewModel.categoryRemoteId, + viewModel.comment + ) + if (firstSavedId == 0L) firstSavedId = billToUseId + } else { + // Create a new bill for this payee + val newBill = DBBill( + 0, 0, bill.projectId, viewModel.payerId, amount, + viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, + bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId + ) + newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) + val newId = db.addBill(newBill) + if (firstSavedId == 0L) firstSavedId = newId + } + } + + // Mark any remaining bills in the original group as deleted + for (id in billsToPool) { + db.setBillState(id, DBBill.STATE_DELETED) + } + + val proj = db.getProject(bill.projectId) + if (proj != null) db.syncIfRemote(proj) + db.updateProject( + bill.projectId, + null, + null, + null, + viewModel.payerId, + null, + null, + null, + null, + null + ) + + return@withContext firstSavedId + } else { + val newAmount = viewModel.amountAsDouble + val newOwersIds = viewModel.getOwersIds() + + if (bill.id != 0L) { + if (valuesHaveChanged()) { + db.updateBillAndSync( + bill, + viewModel.payerId, + newAmount, + viewModel.timestamp, + viewModel.what, + newOwersIds, + viewModel.repeat, + bill.paymentMode, + viewModel.paymentModeRemoteId, + viewModel.categoryRemoteId, + viewModel.comment + ) + if (groupedBillIds != null) { + for (id in groupedBillIds) { + if (id != bill.id) { + db.setBillState(id, DBBill.STATE_DELETED) + } + } + val proj = db.getProject(bill.projectId) + if (proj != null) db.syncIfRemote(proj) + } + } + return@withContext bill.id + } else { + val newBill = DBBill( + 0, 0, bill.projectId, viewModel.payerId, newAmount, + viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, + bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId + ) + newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } + val newBillId = db.addBill(newBill) + db.updateProject( + bill.projectId, + null, + null, + null, + viewModel.payerId, + null, + null, + null, + null, + null + ) + val proj = db.getProject(bill.projectId) + if (proj != null) db.syncIfRemote(proj) + return@withContext newBillId + } + } + } + + companion object { + const val PARAM_BILL_ID = "billId" + const val PARAM_GROUPED_BILL_IDS = "grouped_bill_ids" + const val PARAM_PROJECT_ID = "projectId" + const val PARAM_PROJECT_TYPE = "projectType" + const val PARAM_BILL_ID_TO_DUPLICATE = "billToDuplicate" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt new file mode 100644 index 0000000..242356c --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt @@ -0,0 +1,604 @@ +package net.helcel.cowspent.android.bill_edit + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Comment +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.* +import net.helcel.cowspent.model.* +import net.helcel.cowspent.util.SupportUtil +import java.util.Date +import kotlin.math.abs + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun EditBillScreen( + viewModel: EditBillViewModel, + categories: List, + paymentModes: List, + onSave: () -> Unit, + onBack: () -> Unit, + onDateClick: () -> Unit, + onTimeClick: () -> Unit, + onScan: () -> Unit, + onDelete: (() -> Unit)? = null, + accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN +) { + val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT + val context = LocalContext.current + + StatefulAlertDialog( + state = viewModel.dialogState, + onDismissRequest = { viewModel.dismissDialog() } + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (canEdit) { + IconButton(onClick = onScan) { + Icon(Icons.Default.QrCodeScanner, contentDescription = null) + } + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) + } + } + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + }, + floatingActionButton = { + if (canEdit) { + val errorWhat = stringResource(R.string.error_invalid_bill_what) + val errorDate = stringResource(R.string.error_invalid_bill_date) + val errorPayer = stringResource(R.string.error_invalid_bill_payerid) + val errorOwers = stringResource(R.string.error_invalid_bill_owers) + val errorInvalidForm = stringResource(R.string.simple_error) + + FloatingActionButton(onClick = { + val validationError = viewModel.getValidationError( + errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm + ) + if (validationError == null) { + onSave() + } else { + showToast(context, validationError) + } + }) { + Icon( + Icons.Default.Done, + contentDescription = stringResource(R.string.action_save_bill) + ) + } + } + } + ) { padding -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize() + .verticalScroll(scrollState) + ) { + BillBasicInfoSection( + viewModel = viewModel, + canEdit = canEdit, + onDateClick = onDateClick, + onTimeClick = onTimeClick + ) + + Spacer(modifier = Modifier.height(8.dp)) + + PayerSection( + viewModel = viewModel, + canEdit = canEdit + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OwerSelectionSection( + viewModel = viewModel, + canEdit = canEdit + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BillAdditionalDetailsSection( + viewModel = viewModel, + categories = categories, + paymentModes = paymentModes, + canEdit = canEdit + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +fun BillBasicInfoSection( + viewModel: EditBillViewModel, + canEdit: Boolean, + onDateClick: () -> Unit, + onTimeClick: () -> Unit +) { + val context = LocalContext.current + val currencyDialogTitle = + stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) + val noCurrencyError = stringResource(R.string.no_currency_error) + + OutlinedTextField( + value = viewModel.what, + onValueChange = { viewModel.what = it }, + enabled = canEdit, + placeholder = { Text(stringResource(R.string.setting_what)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = viewModel.amount, + onValueChange = { + viewModel.amount = it + viewModel.updateSplits() + }, + enabled = canEdit, + placeholder = { Text("0") }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.AttachMoney, contentDescription = null) }, + trailingIcon = { + IconButton( + enabled = canEdit, + onClick = { + if (viewModel.currencies.isNotEmpty()) { + viewModel.showDialog( + title = currencyDialogTitle, + items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" }, + onItemSelected = { index -> + viewModel.convertCurrency(viewModel.currencies[index]) + } + ) + } else { + showToast(context, noCurrencyError) + } + } + ) { + Icon(Icons.Default.CurrencyExchange, contentDescription = null) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val dateFormat = + remember(context) { android.text.format.DateFormat.getDateFormat(context) } + val timeFormat = + remember(context) { android.text.format.DateFormat.getTimeFormat(context) } + val dateStr = dateFormat.format(Date(viewModel.timestamp * 1000)) + val timeStr = timeFormat.format(Date(viewModel.timestamp * 1000)) + + Row { + ClickableOutlinedTextField( + value = dateStr, + onClick = onDateClick, + modifier = Modifier.weight(1f), + enabled = canEdit, + leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) } + ) + Spacer(modifier = Modifier.width(8.dp)) + ClickableOutlinedTextField( + value = timeStr, + onClick = onTimeClick, + modifier = Modifier.weight(1f), + enabled = canEdit, + leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = null) } + ) + } +} + +@Composable +fun PayerSection( + viewModel: EditBillViewModel, + canEdit: Boolean +) { + var payerExpanded by remember { mutableStateOf(false) } + val selectedPayer = viewModel.members.find { it.id == viewModel.payerId } + + EditableExposedDropdownMenu( + value = selectedPayer?.name ?: "", + placeholder = stringResource(R.string.setting_payer), + expanded = payerExpanded, + onExpandedChange = { payerExpanded = it }, + onDismissRequest = { payerExpanded = false }, + enabled = canEdit, + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedPayer != null) { + UserAvatar( + name = selectedPayer.name, + r = selectedPayer.r, + g = selectedPayer.g, + b = selectedPayer.b, + disabled = !selectedPayer.isActivated, + size = 24.dp + ) + } else { + Icon(Icons.Default.Person, contentDescription = null) + } + } + }, + content = { + viewModel.members.forEach { member -> + DropdownMenuItem(onClick = { + viewModel.payerId = member.id + payerExpanded = false + }) { + UserAvatar( + name = member.name, + r = member.r, + g = member.g, + b = member.b, + disabled = !member.isActivated, + size = 24.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(member.name) + } + } + } + ) +} + +@Composable +fun OwerSelectionSection( + viewModel: EditBillViewModel, + canEdit: Boolean +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.width(8.dp)) + if (viewModel.owersSelection.all { it.value }) { + IconButton( + enabled = canEdit, + onClick = { + viewModel.members.forEach { viewModel.owersSelection[it.id] = false } + viewModel.updateSplits() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.RemoveDone, + contentDescription = stringResource(R.string.setting_none), + modifier = Modifier.scale(0.8f) + ) + } + } else { + IconButton( + enabled = canEdit, + onClick = { + viewModel.members.forEach { viewModel.owersSelection[it.id] = true } + viewModel.updateSplits() + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.DoneAll, + contentDescription = stringResource(R.string.setting_all), + modifier = Modifier.scale(0.8f) + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(R.string.setting_owers), fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.weight(1f)) + + if (viewModel.isCustomSplit) { + val diff = viewModel.getDiffSplit() + if (abs(diff) > 0.01) { + val diffText = + if (diff > 0) "Missing: ${SupportUtil.normalNumberFormat.format(diff)}" else "Excess: ${ + SupportUtil.normalNumberFormat.format(-diff) + }" + Text( + diffText, + color = MaterialTheme.colors.error, + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } else { + Text("Even Split", fontSize = 12.sp) + } + Switch( + checked = !viewModel.isCustomSplit, + enabled = canEdit, + onCheckedChange = { + viewModel.isCustomSplit = !it + viewModel.updateSplits() + }, + colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.colors.onSurface, + ) + ) + } + + viewModel.members.forEach { member -> + val isSelected = viewModel.owersSelection[member.id] ?: false + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = canEdit) { + viewModel.toggleMember(member.id, !isSelected) + } + + ) { + Checkbox( + checked = isSelected, + enabled = canEdit, + onCheckedChange = { viewModel.toggleMember(member.id, it) } + ) + UserAvatar( + name = member.name, + r = member.r, + g = member.g, + b = member.b, + disabled = !member.isActivated, + size = 32.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(member.name, modifier = Modifier.weight(1f)) + + if (isSelected || viewModel.isCustomSplit) { + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = viewModel.owersCustomSplit[member.id] ?: "", + onValueChange = { + viewModel.owersCustomSplit[member.id] = it + viewModel.owersSelection[member.id] = (it != "") + }, + modifier = Modifier + .width(80.dp) + .height(46.dp), + interactionSource = interactionSource, + singleLine = true, + enabled = viewModel.isCustomSplit && canEdit, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + cursorBrush = SolidColor(MaterialTheme.colors.onSurface), + textStyle = LocalTextStyle.current.copy( + textAlign = TextAlign.Right, + color = MaterialTheme.colors.onSurface + ), + ) { innerTextField -> + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = viewModel.owersCustomSplit[member.id] ?: "", + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + singleLine = true, + enabled = viewModel.isCustomSplit && canEdit, + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + colors = TextFieldDefaults.textFieldColors( + cursorColor = MaterialTheme.colors.onSurface, + backgroundColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + } + } + } + } +} + +@Composable +fun BillAdditionalDetailsSection( + viewModel: EditBillViewModel, + categories: List, + paymentModes: List, + canEdit: Boolean +) { + var categoryExpanded by remember { mutableStateOf(false) } + val selectedCategory = + categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId } + + EditableExposedDropdownMenu( + value = selectedCategory?.name ?: "", + placeholder = stringResource(R.string.setting_category), + expanded = categoryExpanded, + onExpandedChange = { categoryExpanded = it }, + onDismissRequest = { categoryExpanded = false }, + enabled = canEdit, + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedCategory != null) { + Text(text = selectedCategory.icon, fontSize = 20.sp) + } else { + Icon(Icons.Default.Category, contentDescription = null) + } + } + }, + content = { + DropdownMenuItem(onClick = { + viewModel.categoryRemoteId = 0 + categoryExpanded = false + }) { + Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.category_none)) + } + categories.forEach { category -> + DropdownMenuItem(onClick = { + viewModel.categoryRemoteId = category.remoteId.toInt() + categoryExpanded = false + }) { + Text(text = category.icon, fontSize = 20.sp) + Spacer(modifier = Modifier.width(12.dp)) + Text(category.name ?: "") + } + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + var pmExpanded by remember { mutableStateOf(false) } + val selectedPm = + paymentModes.find { it.remoteId.toInt() == viewModel.paymentModeRemoteId } + + EditableExposedDropdownMenu( + value = selectedPm?.name ?: "", + placeholder = stringResource(R.string.setting_payment_mode), + expanded = pmExpanded, + onExpandedChange = { pmExpanded = it }, + onDismissRequest = { pmExpanded = false }, + enabled = canEdit, + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedPm != null) { + Text(text = selectedPm.icon, fontSize = 20.sp) + } else { + Icon(Icons.Default.Payment, contentDescription = null) + } + } + }, + content = { + DropdownMenuItem(onClick = { + viewModel.paymentModeRemoteId = 0 + pmExpanded = false + }) { + Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.payment_mode_none)) + } + paymentModes.forEach { pm -> + DropdownMenuItem(onClick = { + viewModel.paymentModeRemoteId = pm.remoteId.toInt() + pmExpanded = false + }) { + Text(text = pm.icon, fontSize = 20.sp) + Spacer(modifier = Modifier.width(12.dp)) + Text(pm.name ?: "") + } + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val repeatOptions = listOf( + DBBill.NON_REPEATED to stringResource(R.string.repeat_no), + DBBill.REPEAT_DAY to stringResource(R.string.repeat_day), + DBBill.REPEAT_WEEK to stringResource(R.string.repeat_week), + DBBill.REPEAT_FORTNIGHT to stringResource(R.string.repeat_fortnight), + DBBill.REPEAT_MONTH to stringResource(R.string.repeat_month), + DBBill.REPEAT_YEAR to stringResource(R.string.repeat_year) + ) + var repeatExpanded by remember { mutableStateOf(false) } + val selectedRepeat = repeatOptions.find { it.first == viewModel.repeat } + + EditableExposedDropdownMenu( + value = selectedRepeat?.second ?: "", + placeholder = stringResource(R.string.setting_project_repetition), + expanded = repeatExpanded, + onExpandedChange = { repeatExpanded = it }, + onDismissRequest = { repeatExpanded = false }, + enabled = canEdit, + leadingIcon = { Icon(Icons.Default.Repeat, contentDescription = null) }, + content = { + repeatOptions.forEach { (value, label) -> + DropdownMenuItem(onClick = { + viewModel.repeat = value + repeatExpanded = false + }) { + Text(label) + } + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = viewModel.comment, + onValueChange = { viewModel.comment = it }, + enabled = canEdit, + placeholder = { Text(stringResource(R.string.setting_comment)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + leadingIcon = { + Icon( + Icons.AutoMirrored.Filled.Comment, + contentDescription = null + ) + } + ) +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview(showBackground = true) +@Composable +fun EditBillScreenPreview() { + MaterialTheme { + EditBillScreen( + viewModel = EditBillViewModel().apply { + what = "Pizza" + amount = "12.50" + members = listOf( + DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), + DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null) + ) + }, + categories = emptyList(), + paymentModes = emptyList(), + onSave = {}, + onBack = {}, + onDateClick = {}, + onTimeClick = {}, + onScan = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt new file mode 100644 index 0000000..34f0592 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt @@ -0,0 +1,224 @@ +package net.helcel.cowspent.android.bill_edit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.util.SupportUtil +import net.helcel.cowspent.util.evalMath + +import net.helcel.cowspent.model.DBCurrency +import androidx.compose.ui.graphics.vector.ImageVector + +class EditBillViewModel : ViewModel() { + var what by mutableStateOf("") + var amount by mutableStateOf("") + var comment by mutableStateOf("") + var timestamp by mutableLongStateOf(0L) + var payerId by mutableLongStateOf(0L) + var repeat by mutableStateOf(DBBill.NON_REPEATED) + var paymentModeRemoteId by mutableIntStateOf(0) + var categoryRemoteId by mutableIntStateOf(0) + + var currencies by mutableStateOf>(emptyList()) + var mainCurrencyName by mutableStateOf("") + var members by mutableStateOf>(emptyList()) + + var owersSelection = mutableStateMapOf() + var isCustomSplit by mutableStateOf(false) + var owersCustomSplit = mutableStateMapOf() + + var dialogState by mutableStateOf(null) + + val amountAsDouble: Double + get() { + val amountStr = amount.replace(',', '.') + return try { + if (amountStr.matches("[0-9.]+".toRegex())) { + amountStr.toDouble() + } else { + evalMath(amountStr) + } + } catch (_: Exception) { + 0.0 + } + } + + fun getEvenSplit(): Double { + val selectedOwersCount = owersSelection.count { it.value } + return if (selectedOwersCount > 0) amountAsDouble / selectedOwersCount else 0.0 + } + + fun updateSplits() { + if (!isCustomSplit) { + val even = getEvenSplit() + val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString() + members.forEach { m -> + if (owersSelection[m.id] == true) { + owersCustomSplit[m.id] = evenStr + } else { + owersCustomSplit.remove(m.id) + } + } + } + } + + fun toggleMember(id: Long, selected: Boolean) { + owersSelection[id] = selected + if (isCustomSplit) { + if (selected) { + if (owersCustomSplit[id].isNullOrEmpty()) { + owersCustomSplit[id] = "0" + } + } else { + owersCustomSplit.remove(id) + } + } else { + updateSplits() + } + } + + fun getDiffSplit(): Double { + val customTotal = owersCustomSplit.entries + .filter { owersSelection[it.key] == true } + .sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } + return amountAsDouble - customTotal + } + + fun getOwersIds(): List { + return owersSelection.filter { it.value }.keys.toList() + } + + fun showDialog( + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + items: List? = null, + positiveText: String? = null, + negativeText: String? = null, + neutralText: String? = null, + onConfirm: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onNeutral: (() -> Unit)? = null, + onItemSelected: ((Int) -> Unit)? = null + ) { + dialogState = DialogState( + title = title, + message = message, + icon = icon, + items = items, + positiveText = positiveText, + negativeText = negativeText, + neutralText = neutralText, + onConfirm = onConfirm, + onCancel = onCancel, + onNeutral = onNeutral, + onItemSelected = onItemSelected + ) + } + + fun dismissDialog() { + dialogState = null + } + + fun convertCurrency(currency: DBCurrency) { + val originalAmountStr = amount + val originalAmount = amountAsDouble + if (originalAmount == 0.0) return + + val newAmount = originalAmount / currency.exchangeRate + amount = SupportUtil.round2(newAmount).toString() + + val currencyLabel = currency.name ?: "" + val conversionNote = "($originalAmountStr $currencyLabel)" + if (!comment.contains(conversionNote)) { + if (comment.isNotEmpty() && !comment.endsWith(" ")) { + comment += " " + } + comment += conversionNote + } + + if (isCustomSplit) { + owersCustomSplit.keys.toList().forEach { id -> + val value = owersCustomSplit[id] ?: "" + val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0 + if (partAmount != 0.0) { + val newPartAmount = partAmount / currency.exchangeRate + owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString() + } + } + } + + updateSplits() + } + + fun initFromBill(bill: DBBill, members: List, customSplits: Map? = null) { + this.members = members + what = bill.what + amount = if (bill.amount == 0.0) "" else bill.amount.toString() + comment = bill.comment ?: "" + timestamp = bill.timestamp + payerId = bill.payerId + repeat = bill.repeat ?: DBBill.NON_REPEATED + paymentModeRemoteId = bill.paymentModeRemoteId + categoryRemoteId = bill.categoryRemoteId + + owersSelection.clear() + owersCustomSplit.clear() + + if (customSplits != null) { + isCustomSplit = true + for (member in members) { + val selected = customSplits.containsKey(member.id) + owersSelection[member.id] = selected + if (selected) { + owersCustomSplit[member.id] = SupportUtil.round2(customSplits[member.id]!!).toString() + } + } + } else { + val billOwerIds = bill.billOwersIds + val selectedCount = billOwerIds.size + val evenSplit = if (selectedCount > 0) bill.amount / selectedCount else 0.0 + val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString() + + for (member in members) { + val selected = billOwerIds.contains(member.id) + owersSelection[member.id] = selected + if (selected) { + owersCustomSplit[member.id] = evenSplitStr + } + } + } + } + + fun isFormValid(): Boolean { + return what.isNotEmpty() && !what.contains(",") && + timestamp != 0L && + payerId != 0L && + owersSelection.any { it.value } + } + + fun getValidationError( + errorWhat: String, + errorDate: String, + errorPayer: String, + errorOwers: String, + errorInvalidForm: String + ): String? { + return when { + what.isEmpty() || what.contains(",") -> errorWhat + timestamp == 0L -> errorDate + payerId == 0L -> errorPayer + owersSelection.none { it.value } -> errorOwers + !isFormValid() -> errorInvalidForm + else -> null + } + } +} + diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt new file mode 100644 index 0000000..6345092 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt @@ -0,0 +1,85 @@ +package net.helcel.cowspent.android.bill_label + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.CategoryUtils +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.ProjectType + +class LabelBillsActivity : AppCompatActivity() { + private val viewModel: LabelBillsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L) + if (projectId == -1L) { + finish() + return + } + + val db = CowspentSQLiteOpenHelper.getInstance(this) + + lifecycleScope.launch { + val (members, billsToLabel, categories, allCategorized) = withContext(Dispatchers.IO) { + val project = db.getProject(projectId) + val projectType = project?.type ?: ProjectType.LOCAL + + val members = db.getMembersOfProject(projectId, null) + val allBills = db.getBillsOfProject(projectId) + val billsToLabel = allBills.filter { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED } + val allCategorized = allBills.filter { it.categoryRemoteId != 0 && it.state != DBBill.STATE_DELETED } + + val syncedCategories = db.getCategories(projectId) + val defaultCategories = CategoryUtils.getDefaultCategories(this@LabelBillsActivity, projectId) + val hardcoded = if (projectType == ProjectType.LOCAL) { + defaultCategories + } else { + listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) + } + val categories = syncedCategories + hardcoded + + Quadruple(members, billsToLabel, categories, allCategorized) + } + + viewModel.billsToLabel = billsToLabel + viewModel.categories = categories + viewModel.categoriesMap = categories.associateBy { it.remoteId } + viewModel.allCategorizedBills = allCategorized + viewModel.updateSuggestions() + + setContent { + ThemeUtils.CowspentTheme { + LabelBillsScreen( + viewModel = viewModel, + members = members, + db = db, + onBack = { finish() } + ) + } + } + } + } + + private data class Quadruple(val first: A, val second: B, val third: C, val fourth: D) + + companion object { + const val EXTRA_PROJECT_ID = "extra_project_id" + + fun createIntent(context: Context, projectId: Long): Intent { + return Intent(context, LabelBillsActivity::class.java).apply { + putExtra(EXTRA_PROJECT_ID, projectId) + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt new file mode 100644 index 0000000..cf1263a --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt @@ -0,0 +1,269 @@ +package net.helcel.cowspent.android.bill_label + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.SupportUtil + +@Composable +fun LabelBillsScreen( + viewModel: LabelBillsViewModel, + members: List, + db: CowspentSQLiteOpenHelper, + onBack: () -> Unit +) { + val currentBill = viewModel.currentBill + val remainingCount = viewModel.billsToLabel.size - viewModel.currentBillIndex + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.label_bills_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (currentBill != null) { + BillSummaryCard(currentBill, members, remainingCount) + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.label_bills_suggested), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + + val suggestions = viewModel.suggestedCategories + Box(modifier = Modifier.fillMaxWidth().height(64.dp), contentAlignment = Alignment.CenterStart) { + if (suggestions.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + suggestions.forEach { category -> + Box(modifier = Modifier.weight(1f)) { + CategoryButton( + icon = category.icon, + name = category.name ?: "", + onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) } + ) + } + } + repeat(2 - suggestions.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } else { + Text( + text = stringResource(R.string.label_bills_no_suggestions), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.setting_category), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(viewModel.categories) { category -> + CategoryButton( + icon = category.icon, + name = category.name ?: "", + onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) } + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { viewModel.skipCurrentBill() }, + modifier = Modifier.width(128.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray) + ) { + Text(stringResource(R.string.label_bills_skip)) + } + Spacer(modifier = Modifier.height(8.dp)) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.label_bills_no_more)) + } + } + } + } +} + +@Composable +fun BillSummaryCard(bill: DBBill, members: List, remainingCount: Int) { + val payerName = remember(bill.payerId, members) { + members.find { it.id == bill.payerId }?.name ?: bill.payerId.toString() + } + val owersNames = remember(bill.billOwersIds, members) { + bill.billOwersIds.joinToString(", ") { id -> + members.find { it.id == id }?.name ?: id.toString() + } + } + + Box { + Card( + elevation = 4.dp, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(bill.what, maxLines = 2, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold) + if (!bill.comment.isNullOrEmpty()) { + Text(bill.comment!!, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) + } + } + Text( + SupportUtil.normalNumberFormat.format(bill.amount), + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = "$payerName \u2192 $owersNames", + style = MaterialTheme.typography.caption, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = bill.date, + style = MaterialTheme.typography.caption + ) + } + } + } + + Surface( + modifier = Modifier + .size(28.dp), + shape = CircleShape, + color = MaterialTheme.colors.primary, + elevation = 6.dp + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = remainingCount.toString(), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onPrimary, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Composable +fun CategoryButton(icon: String, name: String, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().height(60.dp), + contentPadding = PaddingValues(2.dp) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(icon, fontSize = 20.sp) + Text( + name, + fontSize = 11.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview(showBackground = true) +@Composable +fun LabelBillsScreenPreview() { + val viewModel = LabelBillsViewModel().apply { + billsToLabel = listOf( + DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000, "Groceries at Aldi", 0, null, null, 0, null, -1) + ) + val cats = listOf( + DBCategory(1, 1, 1, "Groceries", "🛒", ""), + DBCategory(2, 2, 1, "Leisure", "🥳", ""), + DBCategory(3, 3, 1, "Rent", "🏠", ""), + DBCategory(4, 4, 1, "Bills", "💸", "") + ) + categories = cats + categoriesMap = cats.associateBy { it.remoteId } + } + val members = listOf( + DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null), + DBMember(2L, 0, 1L, "Bob", true, 1.0, 0, 100, 255, 100, null, null) + ) + MaterialTheme { + LabelBillsScreen( + viewModel = viewModel, + members = members, + db = CowspentSQLiteOpenHelper.getInstance(LocalContext.current), + onBack = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt new file mode 100644 index 0000000..af27cc4 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt @@ -0,0 +1,101 @@ +package net.helcel.cowspent.android.bill_label + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper + +class LabelBillsViewModel : ViewModel() { + var billsToLabel by mutableStateOf>(emptyList()) + internal set + var currentBillIndex by mutableIntStateOf(0) + private set + var categories by mutableStateOf>(emptyList()) + internal set + var suggestedCategories by mutableStateOf>(emptyList()) + private set + + internal var categoriesMap: Map = emptyMap() + internal var allCategorizedBills: List = emptyList() + var onBillProcessed: ((Long) -> Unit)? = null + + val currentBill: DBBill? + get() = if (currentBillIndex < billsToLabel.size) billsToLabel[currentBillIndex] else null + + fun updateSuggestions() { + val bill = currentBill + if (bill == null) { + suggestedCategories = emptyList() + return + } + val name = bill.what.lowercase().trim() + if (name.isEmpty()) { + suggestedCategories = emptyList() + return + } + + val matches = allCategorizedBills.filter { + val otherName = it.what.lowercase().trim() + otherName == name || (name.length > 3 && otherName.contains(name)) || (otherName.length > 3 && name.contains(otherName)) + } + + val counts = matches.groupBy { it.categoryRemoteId } + .mapValues { it.value.size } + .toList() + .sortedByDescending { it.second } + .take(2) + + suggestedCategories = counts.mapNotNull { (catId, _) -> + categoriesMap[catId.toLong()] + } + } + + fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Int) { + currentBill?.let { bill -> + db.updateBillAndSync( + bill = bill, + newPayerId = bill.payerId, + newAmount = bill.amount, + newTimestamp = bill.timestamp, + newWhat = bill.what, + newOwersIds = bill.billOwersIds, + newRepeat = bill.repeat, + newPaymentMode = bill.paymentMode, + newPaymentModeRemoteId = bill.paymentModeRemoteId, + newCategoryId = categoryId, + newComment = bill.comment + ) + bill.categoryRemoteId = categoryId + onBillProcessed?.invoke(bill.id) + moveToNext() + } + } + + fun skipCurrentBill() { + currentBill?.let { bill -> + onBillProcessed?.invoke(bill.id) + } + moveToNext() + } + + private fun moveToNext() { + if (billsToLabel.isEmpty()) return + + val start = currentBillIndex + var next = (start + 1) % billsToLabel.size + while (next != start && billsToLabel[next].categoryRemoteId != 0) { + next = (next + 1) % billsToLabel.size + } + + currentBillIndex = if (billsToLabel[next].categoryRemoteId == 0) { + next + } else { + billsToLabel.size + } + updateSuggestions() + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt new file mode 100644 index 0000000..a0c711b --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt @@ -0,0 +1,145 @@ +package net.helcel.cowspent.android.currencies + +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.showToast +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.ICallback + + +class ManageCurrenciesActivity : AppCompatActivity() { + + private val viewModel: ManageCurrenciesViewModel by viewModels() + private var db: CowspentSQLiteOpenHelper? = null + private var selectedProjectID: Long = -1 + + private val editMainCurrencyCallBack: ICallback = object : ICallback { + override fun onFinish() {} + override fun onFinish(result: String, message: String) { + if (message.isEmpty()) { + showToast(this@ManageCurrenciesActivity,getString(R.string.currency_saved_success), Toast.LENGTH_LONG) + } else { + viewModel.showDialog(title=getString(R.string.error_edit_remote_project_helper, message), + message=getString(R.string.currency_manager), + positiveText = getString(android.R.string.ok)) + } + } + override fun onScheduled() {} + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + intent.extras?.let { + selectedProjectID = it.getLong(EXTRA_PROJECT_ID) + } + if (selectedProjectID == -1L) { + Log.e(TAG, "Missing project id") + finish() + return + } + + db = CowspentSQLiteOpenHelper.getInstance(this) + + lifecycleScope.launch { + val project = withContext(Dispatchers.IO) { db!!.getProject(selectedProjectID) } + viewModel.mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: "" + updateCurrenciesList() + + setContent { + ThemeUtils.CowspentTheme { + ManageCurrenciesScreen( + viewModel = viewModel, + onBack = { finish() }, + onSaveMain = { saveMainCurrency() }, + onAdd = { addCurrency() }, + onDelete = { deleteCurrency(it) } + ) + } + } + } + } + + private fun saveMainCurrency() { + val newMainCurrencyName = viewModel.mainCurrencyName + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db!!.updateProject( + selectedProjectID, null, null, null, + null, null, newMainCurrencyName, + null, null, null + ) + val project = db!!.getProject(selectedProjectID) + if (project != null) { + db!!.syncIfRemote(project) + withContext(Dispatchers.Main) { + if (!db!!.cowspentServerSyncHelper + .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack) + ) { + showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) + } + } + } + } + } + } + + private fun addCurrency() { + val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 } + val newCurrency = DBCurrency( + 0, 0, selectedProjectID, + viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED + ) + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db!!.addCurrencyAndSync(newCurrency) + } + viewModel.newCurrencyName = "" + viewModel.newCurrencyRate = "" + updateCurrenciesList() + } + } + + private fun deleteCurrency(currency: DBCurrency) { + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db!!.setCurrencyStateSync(currency.id, DBBill.STATE_DELETED) + } + updateCurrenciesList() + } + } + + private suspend fun updateCurrenciesList() { + val currenciesDB = withContext(Dispatchers.IO) { + val list = db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_ADDED).toMutableList() + list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_EDITED)) + list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_OK)) + list + } + withContext(Dispatchers.Main) { + viewModel.currencies = currenciesDB + } + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return true + } + + companion object { + private val TAG = ManageCurrenciesActivity::class.java.simpleName + const val EXTRA_PROJECT_ID = "EXTRA_PROJECT_ID" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt new file mode 100644 index 0000000..346fe2a --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt @@ -0,0 +1,178 @@ +package net.helcel.cowspent.android.currencies + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.AlertDialog +import net.helcel.cowspent.model.DBCurrency + +@Composable +fun ManageCurrenciesScreen( + viewModel: ManageCurrenciesViewModel, + onBack: () -> Unit, + onSaveMain: () -> Unit, + onAdd: () -> Unit, + onDelete: (DBCurrency) -> Unit +) { + val dialogState = viewModel.dialogState + if (dialogState != null) { + AlertDialog( + showDialog = true, + onDismissRequest = { viewModel.dismissDialog() }, + title = dialogState.title, + message = dialogState.message, + icon = dialogState.icon, + items = dialogState.items, + positiveText = dialogState.positiveText, + negativeText = dialogState.negativeText, + neutralText = dialogState.neutralText, + onConfirm = { + dialogState.onConfirm?.invoke() + viewModel.dismissDialog() + }, + onCancel = { + dialogState.onCancel?.invoke() + viewModel.dismissDialog() + }, + onNeutral = { + dialogState.onNeutral?.invoke() + viewModel.dismissDialog() + } + ) { + dialogState.onItemSelected?.invoke(it) + viewModel.dismissDialog() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.currency_manager)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize() + ) { + Text(stringResource(R.string.main_currency), style = MaterialTheme.typography.h6) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = viewModel.mainCurrencyName, + onValueChange = { viewModel.mainCurrencyName = it }, + label = { Text(stringResource(R.string.currency_edit_name)) }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = onSaveMain, enabled = viewModel.mainCurrencyName.isNotEmpty()) { + Text(stringResource(R.string.save_or_discard_bill_dialog_save)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = viewModel.newCurrencyName, + onValueChange = { viewModel.newCurrencyName = it }, + label = { Text(stringResource(R.string.currency_edit_name)) }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + value = viewModel.newCurrencyRate, + onValueChange = { viewModel.newCurrencyRate = it }, + label = { Text(stringResource(R.string.currency_rate)) }, + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onAdd, + modifier = Modifier.fillMaxWidth(), + enabled = viewModel.isAddEnabled() + ) { + Text(stringResource(R.string.simple_add)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + LazyColumn(modifier = Modifier.weight(1f)) { + items(viewModel.currencies) { currency -> + CurrencyRow(currency, onDelete = { onDelete(currency) }) + } + } + } + } +} + +@Composable +fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1) + Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) + } + } + Divider() +} + +@Preview(showBackground = true) +@Composable +fun CurrencyRowPreview() { + MaterialTheme { + CurrencyRow( + currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), + onDelete = {} + ) + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview(showBackground = true) +@Composable +fun ManageCurrenciesScreenPreview() { + MaterialTheme { + ManageCurrenciesScreen( + viewModel = ManageCurrenciesViewModel().apply { + mainCurrencyName = "EUR" + currencies = listOf( + DBCurrency(1, 0, 0, "USD", 1.1, 0), + DBCurrency(2, 0, 0, "GBP", 0.85, 0) + ) + }, + onBack = {}, + onSaveMain = {}, + onAdd = {}, + onDelete = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt new file mode 100644 index 0000000..f4e0002 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt @@ -0,0 +1,55 @@ +package net.helcel.cowspent.android.currencies + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.ViewModel +import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.model.DBCurrency + +class ManageCurrenciesViewModel : ViewModel() { + var mainCurrencyName by mutableStateOf("") + var newCurrencyName by mutableStateOf("") + var newCurrencyRate by mutableStateOf("") + + var currencies by mutableStateOf>(emptyList()) + + var dialogState by mutableStateOf(null) + + fun showDialog( + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + items: List? = null, + positiveText: String? = null, + negativeText: String? = null, + neutralText: String? = null, + onConfirm: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onNeutral: (() -> Unit)? = null, + onItemSelected: ((Int) -> Unit)? = null + ) { + dialogState = DialogState( + title = title, + message = message, + icon = icon, + items = items, + positiveText = positiveText, + negativeText = negativeText, + neutralText = neutralText, + onConfirm = onConfirm, + onCancel = onCancel, + onNeutral = onNeutral, + onItemSelected = onItemSelected + ) + } + + fun dismissDialog() { + dialogState = null + } + + fun isAddEnabled(): Boolean { + return newCurrencyName.isNotEmpty() && newCurrencyRate.isNotEmpty() + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt new file mode 100644 index 0000000..a50188d --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt @@ -0,0 +1,350 @@ +package net.helcel.cowspent.android.drawer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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 androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.formatBalance +import net.helcel.cowspent.android.helper.lazyVerticalScrollbar +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType + +@Composable +fun Drawer( + projects: List, + members: List, + memberBalances: Map = emptyMap(), + selectedProjectId: Long, + selectedMemberId: Long?, + lastSyncText: String, + showArchived: Boolean = false, + onProjectClick: (Long) -> Unit, + onProjectOptionsClick: (Long) -> Unit, + onMemberClick: (Long?) -> Unit, + onAddProjectClick: () -> Unit, + onAppSettingsClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.surface), + ) { + DrawerHeader( + lastSyncText = lastSyncText, + onAddProjectClick = onAddProjectClick, + onAppSettingsClick = onAppSettingsClick + ) + + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val maxBottomHeight = maxHeight / 2 + + Column(modifier = Modifier.fillMaxSize()) { + // Projects Section + val activeProjects = projects.filter { !it.isArchived } + .sortedWith(compareByDescending { it.latestBillTs }.thenByDescending { it.id }) + val archivedProjects = projects.filter { it.isArchived }.sortedByDescending { it.archivedTs } + val archivedToDisplay = if (showArchived) archivedProjects else archivedProjects.filter { it.id == selectedProjectId } + + val projectsState = rememberLazyListState() + LazyColumn( + state = projectsState, + modifier = Modifier.weight(1f).lazyVerticalScrollbar(projectsState) + ) { + items(activeProjects) { project -> + ProjectDrawerItem( + project = project, + isSelected = project.id == selectedProjectId, + onClick = { onProjectClick(project.id) }, + onOptionsClick = { onProjectOptionsClick(project.id) } + ) + } + if (archivedToDisplay.isNotEmpty()) { + item { Divider(Modifier.height(4.dp)) } + items(archivedToDisplay) { project -> + ProjectDrawerItem( + project = project, + isSelected = project.id == selectedProjectId, + onClick = { onProjectClick(project.id) }, + onOptionsClick = { onProjectOptionsClick(project.id) }, + alpha = 0.6f, + icon = Icons.Default.Archive + ) + } + } + } + + Divider(Modifier.height(4.dp)) + + // Members Section + val membersState = rememberLazyListState() + LazyColumn( + state = membersState, + modifier = Modifier + .heightIn(max = maxBottomHeight) + .lazyVerticalScrollbar(membersState) + ) { + if (members.isNotEmpty()) { + item { + DrawerItem( + icon = Icons.Default.Receipt, + text = stringResource(R.string.label_all_bills), + selected = selectedMemberId == null, + onClick = { onMemberClick(null) } + ) + } + items(members) { member -> + val balance = memberBalances[member.id] ?: 0.0 + MemberDrawerItem( + member = member, + balance = balance, + isSelected = member.id == selectedMemberId, + onClick = { onMemberClick(member.id) } + ) + } + } + } + } + } + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun DrawerHeader( + lastSyncText: String, + onAddProjectClick: () -> Unit, + onAppSettingsClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.primary) + .padding(16.dp, 4.dp) + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(id = R.string.app_name), + color = MaterialTheme.colors.onPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onAddProjectClick, + modifier = Modifier.scale(0.8f) + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(id = R.string.action_add_project), + tint = MaterialTheme.colors.onPrimary + ) + } + IconButton( + onClick = onAppSettingsClick, + modifier = Modifier.scale(0.8f) + ) { + Icon( + Icons.Default.Settings, + contentDescription = stringResource(id = R.string.action_settings), + tint = MaterialTheme.colors.onPrimary + ) + } + } + if (lastSyncText.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Sync, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colors.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(lastSyncText, color = MaterialTheme.colors.onPrimary, fontSize = 12.sp) + } + } + } + } +} + +@Composable +private fun ProjectDrawerItem( + project: DBProject, + isSelected: Boolean, + onClick: () -> Unit, + onOptionsClick: () -> Unit, + alpha: Float = 1f, + icon: ImageVector = Icons.Default.Folder +) { + val projectName = project.name.ifEmpty { project.remoteId } + DrawerItem( + icon = icon, + text = projectName, + selected = isSelected, + onClick = onClick, + onSecondaryClick = onOptionsClick, + alpha = alpha + ) +} + +@Composable +private fun MemberDrawerItem( + member: DBMember, + balance: Double, + isSelected: Boolean, + onClick: () -> Unit +) { + val balanceText = formatBalance(balance) + val balanceColor = when { + balance > 0.01 -> Color(0xFF4CAF50) + balance < -0.01 -> Color(0xFFF44336) + else -> MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + } + + DrawerItem( + member = member, + balanceText = balanceText.ifEmpty { null }, + balanceColor = balanceColor, + selected = isSelected, + onClick = onClick + ) +} + +@Composable +fun DrawerItem( + icon: ImageVector? = null, + member: DBMember? = null, + text: String? = null, + balanceText: String? = null, + balanceColor: Color = Color.Unspecified, + selected: Boolean = false, + alpha: Float = 1f, + onClick: () -> Unit, + onSecondaryClick: (() -> Unit)? = null +) { + val backgroundColor = if (selected) MaterialTheme.colors.primary.copy(alpha = 0.12f) else Color.Transparent + val contentColor = (if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface).copy(alpha = alpha) + + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = if (member != null) 0.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (member != null) { + UserAvatar( + name = member.name, + r = member.r, + g = member.g, + b = member.b, + avatar = member.avatar, + disabled = !member.isActivated, + size = 24.dp, + alpha = alpha + ) + } else if (icon != null) { + Icon(icon, contentDescription = null, tint = contentColor.copy(alpha = 0.6f * alpha)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + val itemText = text ?: member?.name ?: "" + Text( + text = itemText, + modifier = Modifier.weight(1f), + color = contentColor, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + + if (balanceText != null) { + Text( + text = balanceText, + color = balanceColor, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(end = 8.dp) + ) + } + + if (onSecondaryClick != null) { + IconButton(onClick = onSecondaryClick) { + Icon( + Icons.Default.MoreVert, + contentDescription = null, + tint = contentColor.copy(alpha = 0.6f) + ) + } + } else if (member != null) { + // Equalize height with project items that have a secondary button + Spacer(modifier = Modifier.height(48.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DrawerItemPreview() { + MaterialTheme { + DrawerItem( + icon = Icons.Default.Folder, + text = "My Project", + selected = true, + onClick = {}, + onSecondaryClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DrawerPreview() { + MaterialTheme { + Drawer( + projects = listOf( + DBProject(1, "Vacation", "", "vacation", null, null, null, ProjectType.LOCAL, 0L, null, false, 0, null, null), + DBProject(2, "Home", "", "home", null, null, null, ProjectType.LOCAL, 0L, null, false, 0, null, 123456789L) + ), + members = listOf( + DBMember(1, 0, 1, "Alice", true, 1.0, 0, null, null, null, null, null), + DBMember(2, 0, 1, "Bob", true, 1.0, 0, null, null, null, null, null) + ), + selectedProjectId = 1, + selectedMemberId = null, + lastSyncText = "Last sync: 5 mins ago", + onProjectClick = {}, + onProjectOptionsClick = {}, + onMemberClick = {}, + onAddProjectClick = {}, + onAppSettingsClick = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt new file mode 100644 index 0000000..55ae77f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt @@ -0,0 +1,312 @@ +package net.helcel.cowspent.android.helper + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +data class DialogState( + val title: String? = null, + val message: String? = null, + val icon: ImageVector? = null, + val items: List? = null, + val itemIcons: List? = null, + val positiveText: String? = null, + val negativeText: String? = null, + val neutralText: String? = null, + val onConfirm: (() -> Unit)? = null, + val onCancel: (() -> Unit)? = null, + val onNeutral: (() -> Unit)? = null, + val onItemSelected: ((Int) -> Unit)? = null +) + +@Composable +fun StatefulAlertDialog( + state: DialogState?, + onDismissRequest: () -> Unit +) { + if (state != null) { + AlertDialog( + showDialog = true, + onDismissRequest = onDismissRequest, + title = state.title, + message = state.message, + icon = state.icon, + items = state.items, + itemIcons = state.itemIcons, + positiveText = state.positiveText, + negativeText = state.negativeText, + neutralText = state.neutralText, + onConfirm = { + state.onConfirm?.invoke() + onDismissRequest() + }, + onCancel = { + state.onCancel?.invoke() + onDismissRequest() + }, + onNeutral = { + state.onNeutral?.invoke() + onDismissRequest() + } + ) { + state.onItemSelected?.invoke(it) + onDismissRequest() + } + } +} + +@Composable +fun AlertDialog( + modifier: Modifier = Modifier, + showDialog: Boolean, + onDismissRequest: () -> Unit, + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + items: List? = null, + itemIcons: List? = null, + positiveText: String? = null, + negativeText: String? = null, + neutralText: String? = null, + onConfirm: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onNeutral: (() -> Unit)? = null, + onItemSelected: ((Int) -> Unit)? = null +) { + if (showDialog) { + AlertDialog( + onDismissRequest = onDismissRequest, + shape = MaterialTheme.shapes.large, + title = if (title != null || icon != null) { + { + Row(verticalAlignment = Alignment.CenterVertically) { + if (icon != null) { + Icon(icon, contentDescription = null) + Spacer(Modifier.width(8.dp)) + } + if (title != null) { + Text(text = title) + } + } + } + } else null, + text = if (message != null || items != null) { + { + Column { + if (message != null) { + Text(text = message) + } + if (items != null) { + if (message != null) Spacer(Modifier.height(8.dp)) + LazyColumn { + itemsIndexed(items) { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onItemSelected?.invoke(index) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (itemIcons != null && index < itemIcons.size) { + Icon( + itemIcons[index], + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) + ) + Spacer(Modifier.width(16.dp)) + } + Text( + text = item.toString(), + style = MaterialTheme.typography.body1 + ) + } + } + } + } + } + } + } else null, + buttons = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 8.dp), + horizontalArrangement = Arrangement.End + ) { + if (neutralText != null) { + TextButton(onClick = { onNeutral?.invoke() }) { + Text(neutralText) + } + Spacer(modifier = Modifier.weight(1f)) + } + if (negativeText != null) { + TextButton(onClick = { onCancel?.invoke() }) { + Text(negativeText) + } + } + if (positiveText != null) { + TextButton(onClick = { onConfirm?.invoke() }) { + Text(positiveText) + } + } + } + }, + modifier = modifier + ) + } +} + +@Composable +fun AlertDialogContent( + modifier: Modifier = Modifier, + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + items: Array? = null, + itemIcons: Array? = null, + positiveText: String? = null, + negativeText: String? = null, + neutralText: String? = null, + onConfirm: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onNeutral: (() -> Unit)? = null, + onItemSelected: ((Int) -> Unit)? = null +) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface), + modifier = modifier + ) { + Column(modifier = Modifier.padding(24.dp,16.dp)) { + if (title != null || icon != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp) + ) { + if (icon != null) { + Icon(icon, contentDescription = null) + Spacer(Modifier.width(8.dp)) + } + if (title != null) { + Text(text = title, style = MaterialTheme.typography.h6) + } + } + } + + if (message != null || items != null) { + Column { + if (message != null) { + Text(text = message, style = MaterialTheme.typography.body1) + } + if (items != null) { + if (message != null) Spacer(Modifier.height(8.dp)) + LazyColumn { + itemsIndexed(items) { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onItemSelected?.invoke(index) + } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (itemIcons != null && index < itemIcons.size) { + Icon( + itemIcons[index], + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) + ) + Spacer(Modifier.width(16.dp)) + } + Text( + text = item.toString(), + style = MaterialTheme.typography.body1 + ) + } + } + } + } + } + } + + if (positiveText != null || negativeText != null || neutralText != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + if (neutralText != null) { + TextButton(onClick = { onNeutral?.invoke() }) { + Text(neutralText) + } + Spacer(modifier = Modifier.weight(1f)) + } + if (negativeText != null) { + TextButton(onClick = { onCancel?.invoke() }) { + Text(negativeText) + } + } + if (positiveText != null) { + TextButton(onClick = { onConfirm?.invoke() }) { + Text(positiveText) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun SimpleAlertDialogPreview() { + MaterialTheme { + AlertDialogContent( + title = "Info", + message = "This is a simple alert message.", + positiveText = "OK" + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ConfirmationDialogPreview() { + MaterialTheme { + AlertDialogContent( + title = "Confirm Action", + message = "Are you sure you want to proceed?", + icon = Icons.Default.Info, + positiveText = "Yes", + negativeText = "No" + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ListDialogWithIconsPreview() { + MaterialTheme { + AlertDialogContent( + title = "Select Option", + items = arrayOf("Edit", "Delete", "Share"), + itemIcons = arrayOf(Icons.Default.Edit, Icons.Default.Delete, Icons.Default.Share), + negativeText = "Cancel" + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt b/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt new file mode 100644 index 0000000..12855ad --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt @@ -0,0 +1,298 @@ +package net.helcel.cowspent.android.helper + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.graphics.ColorUtils +import kotlin.math.* +import androidx.core.graphics.toColorInt + + +private fun mLCHtoRBG(l: Float, c: Float, h: Float) : Int{ + val hRad = h * PI / 180 + val a = c * cos(hRad) + val b = c * sin(hRad) + return ColorUtils.LABToColor(l.toDouble(), a, b) +} + + +@Composable +fun ColorPicker( + initialColor: Int, + onColorChanged: (Int) -> Unit +) { + // LCH state + val initialLch = remember(initialColor) { + val lab = DoubleArray(3) + ColorUtils.colorToLAB(initialColor, lab) + val l = lab[0].toFloat() + val c = sqrt(lab[1].pow(2) + lab[2].pow(2)).toFloat() + val h = (atan2(lab[2], lab[1]) * 180 / PI).toFloat().let { if (it < 0) it + 360f else it } + floatArrayOf(l, c, h) + } + + var lightness by remember { mutableFloatStateOf(initialLch[0]) } + var chroma by remember { mutableFloatStateOf(initialLch[1]) } + var hue by remember { mutableFloatStateOf(initialLch[2]) } + + val currentColorInt = remember(lightness, chroma, hue) { + val color = mLCHtoRBG(lightness,chroma,hue) + onColorChanged(color) + color + } + + val currentColor = Color(currentColorInt) + + // HEX state + var hexText by remember { mutableStateOf("%06X".format(0xFFFFFF and currentColorInt)) } + var isHexValid by remember { mutableStateOf(true) } + val interactionSource = remember { MutableInteractionSource() } + + // Sync HEX with LCH + LaunchedEffect(currentColorInt) { + val newHex = "%06X".format(0xFFFFFF and currentColorInt) + if (hexText.uppercase() != newHex) { + hexText = newHex + isHexValid = true + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Preview Area + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .shadow(4.dp, CircleShape) + .clip(CircleShape) + .background(currentColor) + .border(2.dp, Color.White, CircleShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "Selected Color", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + + BasicTextField( + value = hexText, + onValueChange = { newText -> + val filtered = newText.removePrefix("#").filter { it.isDigit() || it.uppercaseChar() in 'A'..'F' }.take(6) + hexText = filtered + if (filtered.length == 6) { + try { + val parsedColor = "#$filtered".toColorInt() + val lab = DoubleArray(3) + ColorUtils.colorToLAB(parsedColor, lab) + lightness = lab[0].toFloat() + chroma = sqrt(lab[1].pow(2) + lab[2].pow(2)).toFloat() + hue = (atan2(lab[2], lab[1]) * 180 / PI).toFloat().let { if (it < 0) it + 360f else it } + isHexValid = true + } catch (_: Exception) { + isHexValid = false + } + } else { + isHexValid = false + } + }, + modifier = Modifier + .width(104.dp) + .heightIn(min = 36.dp), // 1. Bypasses the default 56.dp constraint + textStyle = MaterialTheme.typography.body1.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colors.onSurface // Essential for BasicTextField visibility + ), + visualTransformation = { text -> + TransformedText( + AnnotatedString("#" + text.text), + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + 1 + override fun transformedToOriginal(offset: Int): Int = if (offset < 1) 0 else offset - 1 + } + ) + }, + singleLine = true, + interactionSource = interactionSource + ) { innerTextField -> + @OptIn(ExperimentalMaterialApi::class) + TextFieldDefaults.TextFieldDecorationBox( + value = hexText, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = { text -> + TransformedText( + AnnotatedString("#" + text.text), + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + offset + 1 + + override fun transformedToOriginal(offset: Int): Int = + if (offset < 1) 0 else offset - 1 + } + ) + }, + interactionSource = interactionSource, + isError = !isHexValid, // 2. Tells decoration box to draw the red error indicator line + colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Transparent, + focusedIndicatorColor = currentColor, + unfocusedIndicatorColor = MaterialTheme.colors.onSurface.copy(alpha = 0.3f) + ), + // 3. Reduces the vertical and horizontal internal padding + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + val hueBrush = remember { + Brush.horizontalGradient( + listOf( + Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red + ) + ) + } + LchSlider( + label = "H", + value = hue, + range = 0f..360f, + onValueChange = { hue = it }, + brush = hueBrush + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LchSlider( + label = "C", + value = chroma, + range = 0f..150f, + onValueChange = { chroma = it }, + brush = Brush.horizontalGradient(listOf( + Color(mLCHtoRBG(lightness,0f, hue)), + Color(mLCHtoRBG(lightness,150f,hue)))) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // LCH Sliders + LchSlider( + label = "L", + value = lightness, + range = 0f..100f, + onValueChange = { lightness = it }, + brush = Brush.horizontalGradient(listOf(Color.Black, Color.White)) + ) + } +} + +@Composable +fun LchSlider( + label: String, + value: Float, + range: ClosedFloatingPointRange, + onValueChange: (Float) -> Unit, + brush: Brush +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = MaterialTheme.typography.subtitle2) + Spacer(Modifier.width(Dp(8f))) + Box( + modifier = Modifier + .fillMaxWidth() + .height(28.dp) + .shadow(2.dp, RoundedCornerShape(14.dp)) + .clip(RoundedCornerShape(14.dp)) + .background(brush) + .pointerInput(Unit) { + detectTapGestures { offset -> + val newValue = (offset.x / size.width).coerceIn( + 0f, + 1f + ) * (range.endInclusive - range.start) + range.start + onValueChange(newValue) + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + val newValue = (change.position.x / size.width).coerceIn( + 0f, + 1f + ) * (range.endInclusive - range.start) + range.start + onValueChange(newValue) + } + } + ) { + // Handle + Canvas(modifier = Modifier.fillMaxSize()) { + val fraction = (value - range.start) / (range.endInclusive - range.start) + val x = fraction * size.width + drawCircle( + color = Color.White, + radius = 12.dp.toPx(), + center = Offset(x, size.height / 2), + style = Stroke(width = 3.dp.toPx()) + ) + drawCircle( + color = Color.Black.copy(alpha = 0.2f), + radius = 13.dp.toPx(), + center = Offset(x, size.height / 2), + style = Stroke(width = 1.dp.toPx()) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ColorPickerPreview() { + MaterialTheme { + ColorPicker(initialColor = android.graphics.Color.BLUE, onColorChanged = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/CommonComposables.kt b/app/src/main/java/net/helcel/cowspent/android/helper/CommonComposables.kt new file mode 100644 index 0000000..89b325f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/CommonComposables.kt @@ -0,0 +1,85 @@ +package net.helcel.cowspent.android.helper + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ClickableOutlinedTextField( + value: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null +) { + Box(modifier = modifier) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + enabled = enabled, + placeholder = placeholder, + modifier = Modifier.fillMaxWidth(), + leadingIcon = leadingIcon, + trailingIcon = trailingIcon + ) + Box( + modifier = Modifier + .matchParentSize() + .clickable(enabled = enabled) { onClick() }) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun EditableExposedDropdownMenu( + value: String, + placeholder: String, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, + colors: TextFieldColors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + content: @Composable ColumnScope.() -> Unit +) { + ExposedDropdownMenuBox( + expanded = expanded && enabled, + onExpandedChange = { if (enabled) onExpandedChange(!expanded) }, + modifier = modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + enabled = enabled, + placeholder = { Text(placeholder) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = leadingIcon, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = colors + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest + ) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .verticalScrollbar(scrollState) + .verticalScroll(scrollState), + content = content + ) + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt new file mode 100644 index 0000000..ae57271 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt @@ -0,0 +1,20 @@ +package net.helcel.cowspent.android.helper + +import net.helcel.cowspent.util.SupportUtil +import java.util.Locale +import kotlin.math.abs +import kotlin.math.round + +fun formatShortValue(value: Double): String { + return when { + value >= 1_000_000 -> String.format(Locale.ROOT, "%.1fM", value / 1_000_000).replace(".0", "") + value >= 1_000 -> String.format(Locale.ROOT, "%.1fk", value / 1_000).replace(".0", "") + else -> String.format(Locale.ROOT, "%.0f", value) + } +} + +fun formatBalance(balance: Double): String { + val rbalance = round(abs(balance) * 100.0) / 100.0 + val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else "" + return if (rbalance == 0.0) "" else "$balanceSign${SupportUtil.normalNumberFormat.format(rbalance)}" +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/ModifierUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/ModifierUtils.kt new file mode 100644 index 0000000..e873649 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/ModifierUtils.kt @@ -0,0 +1,70 @@ +package net.helcel.cowspent.android.helper + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun Modifier.verticalScrollbar( + state: ScrollState, + width: Dp = 4.dp, + color: Color = Color.Gray, + alpha: Float = 0.6f +): Modifier = drawWithContent { + drawContent() + val maxValue = state.maxValue.toFloat() + if (maxValue > 0) { + val viewPortHeight = size.height + val totalContentHeight = maxValue + viewPortHeight + val scrollBarHeight = (viewPortHeight * viewPortHeight / totalContentHeight).coerceAtLeast(24.dp.toPx()) + val scrollBarOffset = (state.value.toFloat() / maxValue) * (viewPortHeight - scrollBarHeight) + + drawRoundRect( + color = color, + topLeft = Offset(size.width - width.toPx() - 2.dp.toPx(), scrollBarOffset), + size = Size(width.toPx(), scrollBarHeight), + cornerRadius = CornerRadius(width.toPx() / 2, width.toPx() / 2), + alpha = alpha + ) + } +} + +fun Modifier.lazyVerticalScrollbar( + state: LazyListState, + width: Dp = 4.dp, + color: Color = Color.Gray, + alpha: Float = 0.6f +): Modifier = drawWithContent { + drawContent() + val layoutInfo = state.layoutInfo + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isNotEmpty()) { + val totalItemsCount = layoutInfo.totalItemsCount + val firstVisibleItem = visibleItemsInfo.first() + + val viewPortHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + val avgItemHeight = visibleItemsInfo.map { it.size }.average().toFloat() + val totalContentHeight = avgItemHeight * totalItemsCount + + if (totalContentHeight > viewPortHeight) { + val scrollBarHeight = (viewPortHeight.toFloat() * viewPortHeight / totalContentHeight).coerceAtLeast(24.dp.toPx()) + val firstItemOffset = firstVisibleItem.offset + val scrollOffset = firstVisibleItem.index * avgItemHeight - firstItemOffset + val scrollBarOffset = (scrollOffset / (totalContentHeight - viewPortHeight)) * (viewPortHeight - scrollBarHeight) + + drawRoundRect( + color = color, + topLeft = Offset(size.width - width.toPx() - 2.dp.toPx(), scrollBarOffset), + size = Size(width.toPx(), scrollBarHeight), + cornerRadius = CornerRadius(width.toPx() / 2, width.toPx() / 2), + alpha = alpha + ) + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt new file mode 100644 index 0000000..2c87df7 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt @@ -0,0 +1,226 @@ +package net.helcel.cowspent.android.helper + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.util.Log +import androidx.activity.compose.setContent +import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity +import net.helcel.cowspent.theme.ThemeUtils +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.zxing.* +import com.google.zxing.common.HybridBinarizer +import net.helcel.cowspent.R +import net.helcel.cowspent.android.main.MainConstants +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class QrCodeScannerActivity : AppCompatActivity() { + private lateinit var cameraExecutor: ExecutorService + private var isScanning = true + + override fun onCreate(state: Bundle?) { + super.onCreate(state) + + cameraExecutor = Executors.newSingleThreadExecutor() + + setContent { + ThemeUtils.CowspentTheme { + QrCodeScannerScreen( + onBack = { finish() }, + onResult = { handleResult(it) }, + cameraExecutor = cameraExecutor, + isScanning = isScanning, + onRequestPermission = { + ActivityCompat.requestPermissions( + this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS + ) + }, + hasPermission = allPermissionsGranted() + ) + } + } + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED + } + + override fun onDestroy() { + super.onDestroy() + cameraExecutor.shutdown() + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_PERMISSIONS) { + if (allPermissionsGranted()) { + // Permissions granted, the UI will recompose and start camera + } else { + finish() + } + } + } + + private fun handleResult(result: Result) { + if (!isScanning) return + isScanning = false + + Log.v(TAG, "QR result " + result.text) + + val intent = Intent() + intent.putExtra(MainConstants.KEY_QR_CODE, result.text) + setResult(RESULT_OK, intent) + finish() + } + + companion object { + private val TAG = QrCodeScannerActivity::class.java.simpleName + private const val REQUEST_CODE_PERMISSIONS = 10 + private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA) + } +} + +@Composable +fun QrCodeScannerScreen( + onBack: () -> Unit, + onResult: (Result) -> Unit, + cameraExecutor: ExecutorService, + isScanning: Boolean, + onRequestPermission: () -> Unit, + hasPermission: Boolean +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.scan_qrcode)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.surface, + elevation = 0.dp + ) + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (hasPermission) { + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + }, + modifier = Modifier.fillMaxSize(), + update = { previewView -> + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener({ + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + val preview = androidx.camera.core.Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(cameraExecutor, QrCodeAnalyzer { result -> + if (isScanning) { + onResult(result) + } + }) + } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer + ) + } catch (exc: Exception) { + Log.e("QrCodeScannerScreen", "Use case binding failed", exc) + } + }, ContextCompat.getMainExecutor(context)) + } + ) + } else { + LaunchedEffect(Unit) { + onRequestPermission() + } + } + } + } +} + +private class QrCodeAnalyzer(private val onQrCodeScanned: (Result) -> Unit) : ImageAnalysis.Analyzer { + private val reader = MultiFormatReader().apply { + setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) + } + + @OptIn(ExperimentalGetImage::class) + override fun analyze(image: ImageProxy) { + val buffer = image.planes[0].buffer + val data = ByteArray(buffer.remaining()) + buffer.get(data) + + val source = PlanarYUVLuminanceSource( + data, image.width, image.height, 0, 0, image.width, image.height, false + ) + val binarizer = HybridBinarizer(source) + val binaryBitmap = BinaryBitmap(binarizer) + + try { + val result = reader.decode(binaryBitmap) + onQrCodeScanned(result) + } catch (_: Exception) { + // No QR code found + } finally { + image.close() + } + } +} + +@Preview(showBackground = true) +@Composable +fun QrCodeScannerScreenPreview() { + MaterialTheme { + QrCodeScannerScreen( + onBack = {}, + onResult = {}, + cameraExecutor = Executors.newSingleThreadExecutor(), + isScanning = true, + onRequestPermission = {}, + hasPermission = true + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/TextDrawable.kt b/app/src/main/java/net/helcel/cowspent/android/helper/TextDrawable.kt new file mode 100644 index 0000000..d465e58 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/TextDrawable.kt @@ -0,0 +1,219 @@ +package net.helcel.cowspent.android.helper + +import android.graphics.* +import android.graphics.drawable.Drawable +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.* +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.sqrt + +/** + * A Drawable object that draws text (1 character) on top of a circular/filled background. + */ +class TextDrawable private constructor( + private val mText: String, + r: Int, + g: Int, + b: Int, + private val mRadius: Float, + private val mDisabled: Boolean +) : Drawable() { + private val mTextPaint: Paint = Paint() + private val mBackground: Paint = Paint() + private val mDisabledCircle: Paint = Paint() + + init { + mBackground.style = Paint.Style.FILL + mBackground.isAntiAlias = true + mBackground.color = Color.rgb(r, g, b) + + if ((r + g + b) / 3 < 220) { + mTextPaint.color = Color.WHITE + } else { + mTextPaint.color = Color.BLACK + } + mTextPaint.textSize = mRadius + mTextPaint.isAntiAlias = true + mTextPaint.textAlign = Paint.Align.CENTER + + mDisabledCircle.style = Paint.Style.STROKE + mDisabledCircle.strokeWidth = mRadius * 0.2f + mDisabledCircle.isAntiAlias = true + mDisabledCircle.color = Color.DKGRAY + } + + override fun draw(canvas: Canvas) { + canvas.drawCircle(mRadius, mRadius, mRadius, mBackground) + canvas.drawText( + mText, + mRadius, + mRadius - (mTextPaint.descent() + mTextPaint.ascent()) / 2, + mTextPaint + ) + if (mDisabled) { + canvas.drawCircle(mRadius, mRadius, mRadius * 0.9f, mDisabledCircle) + canvas.drawLine( + mRadius * 0.4f, + mRadius * 1.6f, + mRadius * 1.6f, + mRadius * 0.4f, + mDisabledCircle + ) + } + } + + override fun setAlpha(alpha: Int) { + mTextPaint.alpha = alpha + } + + override fun setColorFilter(cf: ColorFilter?) { + mTextPaint.colorFilter = cf + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + + companion object { + private const val INDEX_RED = 0 + private const val INDEX_GREEN = 1 + private const val INDEX_BLUE = 2 + private const val INDEX_HUE = 0 + private const val INDEX_SATURATION = 1 + private const val INDEX_LUMINATION = 2 + + fun getColorFromName(name: String): Int { + return try { + val hsl = calculateHSL(name) + val rgb = hslToRgb(hsl[0].toFloat(), hsl[1].toFloat(), hsl[2].toFloat(), 1f) + Color.rgb(rgb[0], rgb[1], rgb[2]) + } catch (_: NoSuchAlgorithmException) { + Color.WHITE + } + } + + @Throws(NoSuchAlgorithmException::class) + private fun calculateHSL(name: String): IntArray { + val result = arrayOf("0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0") + val rgb = doubleArrayOf(0.0, 0.0, 0.0) + var sat = 70 + val lum = 68 + val modulo = 16 + + var hash = name.lowercase(Locale.ROOT).replace("[^0-9a-f]".toRegex(), "") + if (!hash.matches("^[0-9a-f]{32}$".toRegex())) { + hash = md5(hash) + } + + for (i in hash.indices) { + result[i % modulo] = (result[i % modulo].toInt() + hash.substring(i, i + 1).toInt(16)).toString() + } + + for (count in 1 until modulo) { + rgb[count % 3] += result[count].toDouble() + } + + rgb[INDEX_RED] = rgb[INDEX_RED] % 255 + rgb[INDEX_GREEN] = rgb[INDEX_GREEN] % 255 + rgb[INDEX_BLUE] = rgb[INDEX_BLUE] % 255 + + val hsl = rgbToHsl(rgb[INDEX_RED], rgb[INDEX_GREEN], rgb[INDEX_BLUE]) + + val bright = sqrt( + 0.299 * rgb[INDEX_RED].pow(2.0) + 0.587 * rgb[INDEX_GREEN].pow(2.0) + 0.114 * rgb[INDEX_BLUE].pow(2.0) + ) + + if (bright >= 200) { + sat = 60 + } + + return intArrayOf((hsl[INDEX_HUE] * 360).toInt(), sat, lum) + } + + private fun hslToRgb(hParam: Float, sParam: Float, lParam: Float, alpha: Float): IntArray { + var h = hParam + var s = sParam + var l = lParam + if (s !in 0.0f..100.0f) { + throw IllegalArgumentException("Color parameter outside of expected range - Saturation") + } + if (l !in 0.0f..100.0f) { + throw IllegalArgumentException("Color parameter outside of expected range - Luminance") + } + if (alpha !in 0.0f..1.0f) { + throw IllegalArgumentException("Color parameter outside of expected range - Alpha") + } + + h %= 360.0f + h /= 360f + s /= 100f + l /= 100f + + val q = if (l < 0.5) { + l * (1 + s) + } else { + (l + s) - s * l + } + val p = 2 * l - q + val r = round(max(0f, hueToRgb(p, q, h + 1.0f / 3.0f)) * 256).toInt() + val g = round(max(0f, hueToRgb(p, q, h)) * 256).toInt() + val b = round(max(0f, hueToRgb(p, q, h - 1.0f / 3.0f)) * 256).toInt() + return intArrayOf(r, g, b) + } + + private fun hueToRgb(p: Float, q: Float, hParam: Float): Float { + var h = hParam + if (h < 0) h += 1f + if (h > 1) h -= 1f + if (6 * h < 1) return p + (q - p) * 6 * h + if (2 * h < 1) return q + if (3 * h < 2) return p + (q - p) * 6 * (2.0f / 3.0f - h) + return p + } + + private fun rgbToHsl(rUntrimmed: Double, gUntrimmed: Double, bUntrimmed: Double): DoubleArray { + val r = rUntrimmed / 255 + val g = gUntrimmed / 255 + val b = bUntrimmed / 255 + val max = max(r, max(g, b)) + val min = r.coerceAtMost(g.coerceAtMost(b)) + var h = (max + min) / 2 + val s: Double + val l = (max + min) / 2 + if (max == min) { + s = 0.0 + h = s // achromatic + } else { + val d = max - min + s = if (l > 0.5) d / (2 - max - min) else d / (max + min) + when (max) { + r -> { + h = (g - b) / d + (if (g < b) 6 else 0) + } + g -> { + h = (b - r) / d + 2 + } + b -> { + h = (r - g) / d + 4 + } + } + h /= 6.0 + } + val hsl = DoubleArray(3) + hsl[INDEX_HUE] = h + hsl[INDEX_SATURATION] = s + hsl[INDEX_LUMINATION] = l + return hsl + } + + @Throws(NoSuchAlgorithmException::class) + private fun md5(string: String): String { + val md5 = MessageDigest.getInstance("MD5").digest(string.toByteArray()) + return md5.joinToString("") { "%02x".format(it) } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/Toaster.kt b/app/src/main/java/net/helcel/cowspent/android/helper/Toaster.kt new file mode 100644 index 0000000..a57bf12 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/Toaster.kt @@ -0,0 +1,9 @@ +package net.helcel.cowspent.android.helper + +import android.content.Context +import android.widget.Toast + + +fun showToast(ctx: Context, text: CharSequence?, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(ctx, text, duration).show() +} \ No newline at end of file diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt b/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt new file mode 100644 index 0000000..30bd1c8 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt @@ -0,0 +1,110 @@ +package net.helcel.cowspent.android.helper + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import android.graphics.BitmapFactory +import android.util.Base64 + +@Composable +fun UserAvatar( + name: String, + modifier: Modifier = Modifier, + size: Dp = 40.dp, + r: Int? = null, + g: Int? = null, + b: Int? = null, + avatar: String? = null, + disabled: Boolean = false, + alpha: Float = 1f +) { + val bitmap = remember(avatar) { + if (!avatar.isNullOrEmpty()) { + try { + val bytes = Base64.decode(avatar, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (_: Exception) { + null + } + } else { + null + } + } + + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = name, + modifier = modifier + .size(size) + .clip(CircleShape), + contentScale = ContentScale.Crop, + alpha = alpha + ) + } else { + val backgroundColor = remember(name, r, g, b) { + if (r != null && g != null && b != null) { + Color(r, g, b) + } else { + Color(TextDrawable.getColorFromName(name)) + } + } + + val initials = name.take(1).uppercase() + val isLight = remember(backgroundColor) { + // Simple luminance check + val luminance = + 0.2126 * backgroundColor.red + 0.7152 * backgroundColor.green + 0.0722 * backgroundColor.blue + luminance > 0.5 + } + + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background((if (disabled) Color.Gray else backgroundColor).copy(alpha = alpha)), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + color = (if (isLight) Color.Black else Color.White).copy(alpha = alpha), + fontSize = (size.value * 0.5).sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Preview +@Composable +fun UserAvatarPreview() { + UserAvatar(name = "Alice") +} + +@Preview +@Composable +fun UserAvatarCustomColorPreview() { + UserAvatar(name = "Bob", r = 255, g = 0, b = 0) +} + +@Preview +@Composable +fun UserAvatarDisabledPreview() { + UserAvatar(name = "Charlie", disabled = true) +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt new file mode 100644 index 0000000..cb855b2 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt @@ -0,0 +1,163 @@ +package net.helcel.cowspent.android.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Receipt +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.util.SupportUtil + +@Composable +fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.no_projects_title), style = MaterialTheme.typography.h6) + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(R.string.no_projects_text)) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onConfigureNextcloud) { + Text(stringResource(R.string.configure_account_choice)) + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onAddManually) { + Text(stringResource(R.string.add_project_choice)) + } + } +} + +@Composable +fun EmptyMembersState() { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.no_members_title), style = MaterialTheme.typography.h6) + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(R.string.no_members_text)) + } +} + +@Composable +fun EmptyBillsState() { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6) + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(R.string.no_bills_text)) + } +} + +@Composable +fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) { + Row { + Spacer(Modifier.width(16.dp)) + Divider(thickness = 1.dp, modifier=Modifier.width(40.dp)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp, 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (payer != null) { + Box { + UserAvatar( + name = payer.name, + r = payer.r, + g = payer.g, + b = payer.b, + avatar = payer.avatar, + disabled = !payer.isActivated, + size = 40.dp + ) + if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) { + Icon( + Icons.Default.Repeat, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterStart) + .size(24.dp) + .offset((-8).dp) + .background(MaterialTheme.colors.onSurface, CircleShape) + .padding(2.dp), + tint = MaterialTheme.colors.surface + ) + } + } + } else { + Icon( + Icons.Default.Receipt, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colors.primary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(bill.formattedWhat.ifEmpty { bill.what }, fontWeight = FontWeight.Bold) + Text( + text = bill.formattedSubtitle.ifEmpty { bill.comment ?: "" }, + style = MaterialTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = SupportUtil.normalNumberFormat.format(bill.amount), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +fun SectionHeader(title: String) { + Surface( + color = MaterialTheme.colors.background, + modifier = Modifier.fillMaxWidth() + ) { + Divider(thickness = 2.dp) + Text( + text = title, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colors.primary + ) + } +} + +@Composable +fun EmptyState() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6) + Text(stringResource(R.string.no_bills_text), modifier = Modifier.padding(16.dp)) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt new file mode 100644 index 0000000..283f037 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt @@ -0,0 +1,586 @@ +package net.helcel.cowspent.android.main + +import android.annotation.SuppressLint +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.preference.PreferenceManager +import kotlinx.coroutines.launch +import net.helcel.cowspent.R +import net.helcel.cowspent.android.drawer.Drawer +import net.helcel.cowspent.android.helper.StatefulAlertDialog +import net.helcel.cowspent.android.project.ProjectOptionsDialogContent +import net.helcel.cowspent.android.project.ProjectShareDialogContent +import net.helcel.cowspent.android.project.member.MemberAddDialogContent +import net.helcel.cowspent.android.project.member.MemberEditDialogContent +import net.helcel.cowspent.android.project.member.MemberManagementDialogContent +import net.helcel.cowspent.android.project.settle.ProjectSettlementDialogContent +import net.helcel.cowspent.android.statistics.ProjectStatisticsActivity +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.SectionItem +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.IRefreshBillsListCallback + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun BillsListScreen( + viewModel: BillsListViewModel, + db: CowspentSQLiteOpenHelper, + refreshCallback: IRefreshBillsListCallback, + onAddBillClick: () -> Unit, + onBillClick: (DBBill) -> Unit, + onProjectClick: (Long) -> Unit, + onProjectOptionsClick: (Long) -> Unit, + onProjectAction: (Long, Int) -> Unit, + onAccountSwitcherClick: () -> Unit, + onAddProjectClick: () -> Unit, + onAppSettingsClick: () -> Unit, + onLabelBillsClick: () -> Unit, + onRefresh: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val scope = rememberCoroutineScope() + var isSearchExpanded by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isSearchExpanded) { + if (isSearchExpanded) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(viewModel.searchQuery) { + refreshCallback.refreshLists(false) + } + + val pullRefreshState = rememberPullRefreshState(viewModel.isRefreshing, onRefresh) + val context = LocalContext.current + val memberAlreadyExistsError = stringResource(R.string.member_already_exists) + val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false) + + StatefulAlertDialog( + state = viewModel.dialogState, + onDismissRequest = { viewModel.dismissDialog() } + ) + + val projectOptionsProjectId = viewModel.showProjectOptionsDialogByProjectId + if (projectOptionsProjectId != null) { + val proj = remember(projectOptionsProjectId, viewModel.projects) { + viewModel.projects.find { it.id == projectOptionsProjectId } + } + Dialog( + onDismissRequest = { viewModel.showProjectOptionsDialogByProjectId = null }, + ) { + ProjectOptionsDialogContent( + onEditProject = { + onProjectAction(projectOptionsProjectId, 0) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onRemoveProject = { + onProjectAction(projectOptionsProjectId, 1) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onManageMembers = { + onProjectAction(projectOptionsProjectId, 2) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onManageCurrencies = { + onProjectAction(projectOptionsProjectId, 3) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onStatistics = { + onProjectAction(projectOptionsProjectId, 4) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onSettle = { + onProjectAction(projectOptionsProjectId, 5) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onShareProject = { + onProjectAction(projectOptionsProjectId, 6) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onExportProject = { + onProjectAction(projectOptionsProjectId, 7) + viewModel.showProjectOptionsDialogByProjectId = null + }, + onDismiss = { viewModel.showProjectOptionsDialogByProjectId = null }, + isArchived = proj?.isArchived == true, + accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN, + isShareable = proj?.isShareable() ?: true + ) + } + } + + val settlementProjectId = viewModel.showSettlementDialogByProjectId + if (settlementProjectId != null) { + val proj = remember(settlementProjectId, viewModel.projects) { + viewModel.projects.find { it.id == settlementProjectId } + } + if (proj != null) { + Dialog( + onDismissRequest = { viewModel.showSettlementDialogByProjectId = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + ProjectSettlementDialogContent( + proj = proj, + db = db, + onSettleBills = { transactions -> + BillsListUtils.createBillsFromTransactions( + db, + settlementProjectId, + transactions, + refreshCallback, + context + ) + viewModel.showSettlementDialogByProjectId = null + }, + onShare = { transactions, memberIdToName -> + BillsListUtils.shareSettlement(context, proj, transactions, memberIdToName) + }, + onDismiss = { viewModel.showSettlementDialogByProjectId = null } + ) + } + } + } + + val statisticsProjectId = viewModel.showStatisticsDialogByProjectId + if (statisticsProjectId != null) { + LaunchedEffect(statisticsProjectId) { + context.startActivity( + ProjectStatisticsActivity.createIntent( + context, + statisticsProjectId + ) + ) + viewModel.showStatisticsDialogByProjectId = null + } + } + + val manageMembersProjectId = viewModel.showMemberManagementDialogByProjectId + if (manageMembersProjectId != null) { + val members = viewModel.members // Use members from ViewModel as they are already loaded + Dialog( + onDismissRequest = { viewModel.showMemberManagementDialogByProjectId = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + MemberManagementDialogContent( + members = members, + onAddMember = { + viewModel.showAddMemberDialogByProjectId = manageMembersProjectId + viewModel.showMemberManagementDialogByProjectId = null + }, + onEditMember = { member -> + viewModel.showEditMemberDialogByProjectId = member.id + viewModel.showMemberManagementDialogByProjectId = null + }, + onDismiss = { viewModel.showMemberManagementDialogByProjectId = null } + ) + } + } + + val addMemberProjectId = viewModel.showAddMemberDialogByProjectId + if (addMemberProjectId != null) { + Dialog( + onDismissRequest = { viewModel.showAddMemberDialogByProjectId = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + MemberAddDialogContent( + onAdd = { memberName -> + val memberNames = + db.getMembersOfProject(addMemberProjectId, null).map { it.name } + if (memberNames.contains(memberName)) { + Toast.makeText( + context, + memberAlreadyExistsError, + Toast.LENGTH_SHORT + ).show() + } else { + val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName) + db.addMemberAndSync( + DBMember( + 0, + 0, + addMemberProjectId, + memberName, + true, + 1.0, + DBBill.STATE_ADDED, + android.graphics.Color.red(color), + android.graphics.Color.green(color), + android.graphics.Color.blue(color), + null, + null + ) + ) + refreshCallback.refreshLists(false) + viewModel.showAddMemberDialogByProjectId = null + viewModel.showMemberManagementDialogByProjectId = addMemberProjectId + } + }, + onDismiss = { + viewModel.showAddMemberDialogByProjectId = null + viewModel.showMemberManagementDialogByProjectId = addMemberProjectId + } + ) + } + } + + val editMemberId = viewModel.showEditMemberDialogByProjectId + if (editMemberId != null) { + val memberToEdit = remember(editMemberId, viewModel.members) { + viewModel.members.find { it.id == editMemberId } + } + if (memberToEdit != null) { + Dialog( + onDismissRequest = { viewModel.showEditMemberDialogByProjectId = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + MemberEditDialogContent( + member = memberToEdit, + onSave = { name, weight, isActivated, r, g, b -> + db.updateMemberAndSync( + memberToEdit, + name, + weight, + isActivated, + r, + g, + b, + "", + "" + ) + refreshCallback.refreshLists(false) + viewModel.showEditMemberDialogByProjectId = null + viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId + }, + onDelete = { + db.deleteMember(editMemberId) + refreshCallback.refreshLists(false) + viewModel.showEditMemberDialogByProjectId = null + viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId + }, + onDismiss = { + viewModel.showEditMemberDialogByProjectId = null + viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId + } + ) + } + } + } + + val shareProjectId = viewModel.showShareDialogByProjectId + if (shareProjectId != null) { + val proj = remember(shareProjectId, viewModel.projects) { + viewModel.projects.find { it.id == shareProjectId } + } + if (proj != null) { + val shareIntentTitle = stringResource(R.string.share_share_intent_title, proj.name) + val shareChooserTitle = stringResource(R.string.share_share_chooser_title, proj.name) + Dialog( + onDismissRequest = { viewModel.showShareDialogByProjectId = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + ProjectShareDialogContent( + proj = proj, + onShare = { shareUrl -> + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, shareIntentTitle) + putExtra(Intent.EXTRA_TEXT, shareUrl) + } + val chooserIntent = Intent.createChooser(shareIntent, shareChooserTitle) + context.startActivity(chooserIntent) + }, + onDismiss = { viewModel.showShareDialogByProjectId = null } + ) + } + } + } + + Scaffold( + scaffoldState = scaffoldState, + drawerShape = RectangleShape, + topBar = { + TopAppBar( + title = { + if (isSearchExpanded) { + TextField( + value = viewModel.searchQuery, + onValueChange = { viewModel.searchQuery = it }, + placeholder = { Text(stringResource(R.string.action_search), color = MaterialTheme.colors.onPrimary.copy(alpha = 0.7f)) }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Transparent, + cursorColor = MaterialTheme.colors.onPrimary, + textColor = MaterialTheme.colors.onPrimary, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + singleLine = true, + trailingIcon = { + IconButton(onClick = { + isSearchExpanded = false + viewModel.searchQuery = "" + }) { + Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.onPrimary) + } + } + ) + } else { + Column { + if (viewModel.title.isNotEmpty()) Text(viewModel.title) + else Text(stringResource(R.string.app_name)) + } + } + }, + navigationIcon = { + if (isSearchExpanded) { + IconButton(onClick = { + isSearchExpanded = false + viewModel.searchQuery = "" + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + } else { + IconButton(onClick = { + scope.launch { scaffoldState.drawerState.open() } + }) { + Icon(Icons.Default.Menu, contentDescription = null) + } + } + }, + actions = { + if (!isSearchExpanded) { + if (viewModel.hasUnlabeledBills) { + IconButton(onClick = onLabelBillsClick) { + Icon( + Icons.Default.Category, + contentDescription = stringResource(R.string.action_label_bills) + ) + } + } + IconButton(onClick = { isSearchExpanded = true }) { + Icon(Icons.Default.Search, contentDescription = null) + } + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 4.dp + ) + }, + floatingActionButton = { + val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId } + if (selectedProject != null && !selectedProject.isArchived) { + FloatingActionButton(onClick = onAddBillClick) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_create_bill)) + } + } + }, + drawerContent = { + Drawer( + projects = viewModel.projects, + members = viewModel.members, + memberBalances = viewModel.memberBalances, + selectedProjectId = viewModel.selectedProjectId, + selectedMemberId = viewModel.selectedMemberId, + lastSyncText = viewModel.lastSyncText, + showArchived = showArchived, + onProjectClick = { + viewModel.selectedMemberId = null + onProjectClick(it) + scope.launch { scaffoldState.drawerState.close() } + }, + onProjectOptionsClick = { + onProjectOptionsClick(it) + }, + onMemberClick = { memberId -> + viewModel.selectedMemberId = memberId + refreshCallback.refreshLists(false) + scope.launch { scaffoldState.drawerState.close() } + }, + onAddProjectClick = { + onAddProjectClick() + scope.launch { scaffoldState.drawerState.close() } + }, + onAppSettingsClick = { + onAppSettingsClick() + scope.launch { scaffoldState.drawerState.close() } + } + ) + } + ) { padding -> + // Pull-to-refresh is not officially in Material 2 basic components, + // but we can use SwipeRefresh from accompanist or implement it manually. + // For simplicity and following common practices, I'll assume standard swipe refresh logic. + // Actually, there is androidx.compose.material.pullrefresh.pullRefresh in later Material 2. + + Box(modifier = Modifier + .padding(padding) + .fillMaxSize() + .pullRefresh(pullRefreshState)) { + when { + viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick) + viewModel.showNoMembers -> EmptyMembersState() + viewModel.showNoBills -> EmptyBillsState() + viewModel.bills.isEmpty() -> EmptyState() + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(viewModel.bills) { item -> + when (item) { + is DBBill -> { + val payer = viewModel.members.find { it.id == item.payerId } + BillItemRow(item, payer, onClick = { onBillClick(item) }) + } + is SectionItem -> SectionHeader(item.title) + } + } + } + } + } + PullRefreshIndicator( + refreshing = viewModel.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } +} + + + +@Preview(showBackground = true) +@Composable +fun BillItemRowPreview() { + MaterialTheme { + BillItemRow( + bill = DBBill(0, 0, 0, 1, 15.0, System.currentTimeMillis() / 1000, "Dinner", 0, "n", null, 0, "", 0).apply { + formattedWhat = "Dinner" + formattedSubtitle = "Alice \u2192 Group" + }, + payer = DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), + onClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SectionHeaderPreview() { + MaterialTheme { + SectionHeader(title = "October 2023") + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview(showBackground = true) +@Composable +fun BillsListScreenPreview() { + MaterialTheme { + BillsListScreen( + viewModel = BillsListViewModel().apply { + title = "Demo Project" + members = listOf( + DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null) + ) + bills = listOf( + SectionItem("Today"), + DBBill(0, 0, 0, 1, 10.0, System.currentTimeMillis() / 1000, "Lunch", 0, "n", null, 0, "", 0).apply { + formattedWhat = "Lunch" + formattedSubtitle = "Alice \u2192 Group" + } + ) + }, + db = CowspentSQLiteOpenHelper.getInstance(LocalContext.current), + refreshCallback = object : IRefreshBillsListCallback { + override fun refreshLists(scrollToTop: Boolean) {} + }, + onAddBillClick = {}, + onBillClick = {}, + onProjectClick = {}, + onProjectOptionsClick = {}, + onProjectAction = { _, _ -> }, + onAccountSwitcherClick = {}, + onAddProjectClick = {}, + onAppSettingsClick = {}, + onLabelBillsClick = {}, + onRefresh = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun EmptyProjectsStatePreview() { + MaterialTheme { + EmptyProjectsState(onConfigureNextcloud = {}, onAddManually = {}) + } +} + +@Preview(showBackground = true) +@Composable +fun EmptyMembersStatePreview() { + MaterialTheme { + EmptyMembersState() + } +} + +@Preview(showBackground = true) +@Composable +fun EmptyBillsStatePreview() { + MaterialTheme { + EmptyBillsState() + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt new file mode 100644 index 0000000..b0eee62 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt @@ -0,0 +1,117 @@ +package net.helcel.cowspent.android.main + +import android.content.Context +import android.content.Intent +import net.helcel.cowspent.R +import net.helcel.cowspent.model.* +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.IRefreshBillsListCallback +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.round + +object BillsListUtils { + fun groupAndSectionBills( + bills: List, + membersMap: Map, + sdf: SimpleDateFormat, + context: Context + ): List { + val groupedBillsList = mutableListOf() + val groups = bills.groupBy { "${it.what}|${it.date}|${it.time}|${it.payerId}" } + val seenGroups = mutableSetOf() + + for (bill in bills) { + val groupKey = "${bill.what}|${bill.date}|${bill.time}|${bill.payerId}" + if (groupKey !in seenGroups) { + val group = groups[groupKey]!! + if (group.size == 1) { + groupedBillsList.add(bill) + } else { + val groupedBill = GroupedBill(group) + val payerName = membersMap[groupedBill.payerId]?.name ?: groupedBill.payerId.toString() + val allOwerIds = group.flatMap { it.billOwersIds }.distinct() + val owersNames = allOwerIds.joinToString(", ") { id -> + membersMap[id]?.name ?: id.toString() + } + groupedBill.formattedSubtitle = "$payerName \u2192 $owersNames" + groupedBillsList.add(groupedBill) + } + seenGroups.add(groupKey) + } + } + + val itemList: MutableList = ArrayList() + var lastDate = "" + val androidDateFormat = android.text.format.DateFormat.getDateFormat(context) + + for (bill in groupedBillsList) { + val billDate = bill.date + if (billDate != lastDate) { + val date = try { sdf.parse(billDate) } catch (_: Exception) { null } + val formattedDate = date?.let { androidDateFormat.format(it) } ?: billDate + itemList.add(SectionItem(formattedDate)) + lastDate = billDate + } + itemList.add(bill) + } + return itemList + } + + fun shareSettlement( + context: Context, + proj: DBProject, + transactions: List, + memberIdToName: Map + ) { + val projectName = proj.name.ifEmpty { proj.remoteId } + var text = context.getString(R.string.share_settle_intro, projectName) + "\n" + for (t in transactions) { + val amount = round(t.amount * 100.0) / 100.0 + text += "\n" + context.getString( + R.string.share_settle_sentence, + memberIdToName[t.owerMemberId], + memberIdToName[t.receiverMemberId], + amount + ) + } + val shareIntent = Intent() + shareIntent.action = Intent.ACTION_SEND + shareIntent.type = "text/plain" + shareIntent.putExtra( + Intent.EXTRA_SUBJECT, + context.getString(R.string.share_settle_title, projectName) + ) + shareIntent.putExtra(Intent.EXTRA_TEXT, text) + val chooserIntent = Intent.createChooser( + shareIntent, + context.getString(R.string.share_settle_title, projectName) + ) + context.startActivity(chooserIntent) + } + + fun createBillsFromTransactions( + db: CowspentSQLiteOpenHelper, + projectId: Long, + transactions: List, + refreshCallback: IRefreshBillsListCallback, + context: Context + ) { + val timestamp = System.currentTimeMillis() / 1000 + for (t in transactions) { + val owerId = t.owerMemberId + val receiverId = t.receiverMemberId + val amount = t.amount + val bill = DBBill( + 0, 0, projectId, owerId, amount, + timestamp, context.getString(R.string.settle_bill_what), + DBBill.STATE_ADDED, DBBill.NON_REPEATED, + DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, + "", DBBill.PAYMODE_ID_NONE + ) + bill.billOwers += DBBillOwer(0, 0, receiverId) + db.addBill(bill) + } + refreshCallback.refreshLists(true) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt new file mode 100644 index 0000000..06df8f1 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt @@ -0,0 +1,827 @@ +package net.helcel.cowspent.android.main + +import android.Manifest +import android.app.SearchManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddCircleOutline +import androidx.compose.material.icons.filled.Sync +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import com.nextcloud.android.sso.helper.SingleAccountHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.android.account.AccountActivity +import net.helcel.cowspent.android.bill_edit.EditBillActivity +import net.helcel.cowspent.android.bill_label.LabelBillsActivity +import net.helcel.cowspent.android.currencies.ManageCurrenciesActivity +import net.helcel.cowspent.android.helper.showToast +import net.helcel.cowspent.android.project.create.NewProjectActivity +import net.helcel.cowspent.android.project.edit.EditProjectActivity +import net.helcel.cowspent.android.settings.PreferencesActivity +import net.helcel.cowspent.model.Category +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.GroupedBill +import net.helcel.cowspent.model.ProjectType +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.persistence.CowspentServerSyncHelper +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.BillFormatter +import net.helcel.cowspent.util.CospendClientUtil +import net.helcel.cowspent.util.ExportUtil +import net.helcel.cowspent.util.ICallback +import net.helcel.cowspent.util.IRefreshBillsListCallback +import net.helcel.cowspent.util.SupportUtil +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class BillsListViewActivity : + AppCompatActivity(), + IRefreshBillsListCallback { + + private val viewModel: BillsListViewModel by viewModels() + + companion object { + var DEBUG = false + + private val TAG = BillsListViewActivity::class.java.simpleName + + private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection" + private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen" + + private var contentToExport = "" + var isActivityVisible = false + private set + } + + private var navigationSelection = Category(null, null) + private var navigationOpen: String? = "" + + private var mActionMode: ActionMode? = null + private lateinit var db: CowspentSQLiteOpenHelper + + private val syncCallBack = object : ICallback { + override fun onFinish() { + mActionMode?.finish() + refreshLists() + viewModel.isRefreshing = false + } + + override fun onFinish(result: String, message: String) {} + + override fun onScheduled() { + viewModel.isRefreshing = false + } + } + + private val addProjectLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + var pid = data.getLongExtra(MainConstants.CREATED_PROJECT, 0) + var created = true + if (pid == 0L) { + created = false + pid = data.getLongExtra(MainConstants.ADDED_PROJECT, 0) + } + if (DEBUG) Log.d(TAG, "BILLS request code : addproject $pid") + if (pid != 0L) { + viewModel.selectedMemberId = null + setSelectedProject(pid) + Log.d(TAG, "CREATED project id: $pid") + lifecycleScope.launch { + val addedProj = withContext(Dispatchers.IO) { db.getProject(pid) } + val message: String + val title: String + if (created) { + Log.e(TAG, "CREATED !!!") + title = getString(R.string.project_create_success_title) + message = getString(R.string.project_create_success_message, addedProj?.remoteId) + } else { + Log.e(TAG, "ADDED !!!") + title = getString(R.string.project_add_success_title) + message = getString(R.string.project_add_success_message, addedProj?.remoteId) + } + showDialog(message, title, Icons.Default.AddCircleOutline) + } + } + } + setupDrawerProjects() + } + + private val serverSettingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + updateUsernameInDrawer() + db = CowspentSQLiteOpenHelper.getInstance(this) + if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { + db.cowspentServerSyncHelper.runAccountProjectsSync() + } + if (!db.cowspentServerSyncHelper.isSyncPossible) { + if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { + Toast.makeText(applicationContext, getString(R.string.error_sync, getString(CospendClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show() + } + } + } + } + + private val createBillLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + } + + private val editBillLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + val billId = data.getLongExtra(MainConstants.BILL_TO_DUPLICATE, 0) + if (billId != 0L) { + duplicateBill(billId) + } + } + } + + private val editProjectLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + var pid = data.getLongExtra(MainConstants.DELETED_PROJECT, 0) + if (pid != 0L) { + setSelectedProject(0) + } + pid = data.getLongExtra(MainConstants.EDITED_PROJECT, 0) + if (pid != 0L) { + viewModel.selectedMemberId = null + setSelectedProject(pid) + } + } + setupDrawerProjects() + } + + private val labelBillsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + refreshLists() + } + + private val saveFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + val fileUri = data.data + fileUri?.let { saveToFileUri(contentToExport, it) } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + isActivityVisible = true + if (savedInstanceState != null) { + navigationSelection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + savedInstanceState.getSerializable(SAVED_STATE_NAVIGATION_SELECTION, Category::class.java)!! + } else { + @Suppress("DEPRECATION") + savedInstanceState.getSerializable(SAVED_STATE_NAVIGATION_SELECTION) as Category + } + navigationOpen = savedInstanceState.getString(SAVED_STATE_NAVIGATION_OPEN) + } + + db = CowspentSQLiteOpenHelper.getInstance(this) + + setupDrawerProjects() + + updateUsernameInDrawer() + + setContent { + ThemeUtils.CowspentTheme { + BillsListScreen( + viewModel = viewModel, + db = db, + refreshCallback = this, + onAddBillClick = { + val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + if (selectedProjectId != 0L) { + lifecycleScope.launch { + val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) } + if (members.isEmpty()) { + showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member)) + } else { + val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } + val createIntent = Intent(applicationContext, EditBillActivity::class.java).apply { + putExtra(EditBillActivity.PARAM_PROJECT_ID, selectedProjectId) + putExtra(EditBillActivity.PARAM_PROJECT_TYPE, proj?.type?.id) + } + createBillLauncher.launch(createIntent) + } + } + } + }, + onBillClick = { bill: DBBill -> + lifecycleScope.launch { + val pid = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + val proj = withContext(Dispatchers.IO) { db.getProject(pid) } + val intent = Intent(applicationContext, EditBillActivity::class.java).apply { + if (bill is GroupedBill) { + val ids = bill.sourceBills.map { it.id }.toLongArray() + putExtra(EditBillActivity.PARAM_GROUPED_BILL_IDS, ids) + } else { + putExtra(EditBillActivity.PARAM_BILL_ID, bill.id) + } + putExtra(EditBillActivity.PARAM_PROJECT_TYPE, proj?.type?.id) + putExtra(EditBillActivity.PARAM_PROJECT_ID, pid) + } + editBillLauncher.launch(intent) + } + }, + onProjectClick = { pid: Long -> onProjectClick(pid) }, + onProjectOptionsClick = { pid: Long -> onManageProjectClick(pid) }, + onProjectAction = { pid, actionIndex -> + when (actionIndex) { + 0 -> onEditProjectClick(pid) + 1 -> onRemoveProjectClick(pid) + 2 -> onManageMembersClick(pid) + 3 -> onManageCurrenciesClick(pid) + 4 -> onProjectStatisticsClick(pid) + 5 -> onSettleProjectClick(pid) + 6 -> onShareProjectClick(pid) + 7 -> onExportProjectClick(pid) + } + }, + onAccountSwitcherClick = { + serverSettingsLauncher.launch(Intent(this, AccountActivity::class.java)) + }, + onAddProjectClick = { addProject() }, + onAppSettingsClick = { + serverSettingsLauncher.launch(Intent(this, PreferencesActivity::class.java)) + }, + onLabelBillsClick = { + val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + if (selectedProjectId != 0L) { + labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId)) + } + }, + onRefresh = { synchronize(true) } + ) + } + } + + lifecycleScope.launch { + val empty = withContext(Dispatchers.IO) { db.projects.isEmpty() } + if (empty && !CowspentServerSyncHelper.isNextcloudAccountConfigured(this@BillsListViewActivity)) { + viewModel.showNoProjects = true + } + + val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val selectedProjectId = preferences.getLong("selected_project", 0) + if (selectedProjectId == 0L) { + val dbProjects = withContext(Dispatchers.IO) { db.projects } + if (dbProjects.isNotEmpty()) { + setSelectedProject(dbProjects[0].id) + } + } + } + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + finish() + } + }) + + val projectToSelect = intent.getLongExtra(MainConstants.PARAM_PROJECT_TO_SELECT, 0) + if (projectToSelect != 0L) { + setSelectedProject(projectToSelect) + lifecycleScope.launch { + val project = withContext(Dispatchers.IO) { db.getProject(projectToSelect) } + val dialogContent = intent.getStringExtra(MainConstants.PARAM_DIALOG_CONTENT) + if (dialogContent != null && project != null) { + viewModel.showDialog( + title = getString(R.string.activity_dialog_title, project.name), + message = dialogContent, + positiveText = getString(android.R.string.ok), + icon = Icons.Default.Sync + ) + } + } + } + } + + override fun onResume() { + super.onResume() + val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val selectedProjectId = preferences.getLong("selected_project", 0) + if (selectedProjectId != 0L) { + refreshLists() + } + viewModel.isRefreshing = false + + if (db.cowspentServerSyncHelper.isSyncPossible) { + db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) + synchronize() + } + + registerBroadcastReceiver() + updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) + isActivityVisible = true + } + + override fun onPause() { + super.onPause() + try { + unregisterReceiver(mBroadcastReceiver) + } catch (_: RuntimeException) { + if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...") + } + isActivityVisible = false + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putSerializable(SAVED_STATE_NAVIGATION_SELECTION, navigationSelection) + outState.putString(SAVED_STATE_NAVIGATION_OPEN, navigationOpen) + } + + private fun setupDrawerProjects() { + val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + lifecycleScope.launch { + val projects = withContext(Dispatchers.IO) { db.projects } + viewModel.projects = projects + setSelectedProject(selectedProjectId) + } + } + + fun onProjectClick(projectId: Long) { + if (viewModel.selectedProjectId != projectId) { + viewModel.selectedMemberId = null + } + setSelectedProject(projectId) + navigationSelection = Category(null, null) + refreshLists(true) + + synchronize() + } + + fun onManageProjectClick(projectId: Long) { + viewModel.showProjectOptionsDialogByProjectId = projectId + } + + private fun onEditProjectClick(projectId: Long) { + if (projectId == 0L) return + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } + if (proj?.isLocal == false) { + val intent = Intent(applicationContext, EditProjectActivity::class.java).apply { + putExtra(EditProjectActivity.PARAM_PROJECT_ID, projectId) + } + editProjectLauncher.launch(intent) + } else { + showToast(this@BillsListViewActivity, getString(R.string.edit_project_local_impossible)) + } + } + } + + private fun onRemoveProjectClick(projectId: Long) { + if (projectId == 0L) return + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch + + viewModel.showDialog( + title = getString(R.string.confirm_remove_project_dialog_title), + message = if (!proj.isLocal) getString(R.string.confirm_remove_project_dialog_message) else null, + positiveText = getString(R.string.simple_yes), + onConfirm = { + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.deleteProject(projectId) + val dbProjects = db.projects + if (dbProjects.isNotEmpty()) setSelectedProject(dbProjects[0].id) else setSelectedProject(0) + } + setupDrawerProjects() + refreshLists() + synchronize() + val projectNameString = proj.name.ifEmpty { proj.remoteId } + showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString)) + } + }, + negativeText = getString(R.string.simple_no) + ) + } + } + + fun onManageMembersClick(projectId: Long) { + if (projectId == 0L) return + lifecycleScope.launch { + val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch + if (project.myAccessLevel != DBProject.ACCESS_LEVEL_UNKNOWN && project.myAccessLevel < DBProject.ACCESS_LEVEL_MAINTAINER) { + showToast(this@BillsListViewActivity, getString(R.string.insufficient_access_level)) + return@launch + } + + viewModel.showMemberManagementDialogByProjectId = projectId + } + } + + fun onManageCurrenciesClick(projectId: Long) { + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } + if (proj != null && proj.type == ProjectType.COSPEND) { + startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply { + putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId) + }) + } else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable)) + } + } + + fun onProjectStatisticsClick(projectId: Long) { + viewModel.showStatisticsDialogByProjectId = projectId + } + + fun onSettleProjectClick(projectId: Long) { + viewModel.showSettlementDialogByProjectId = projectId + } + + fun onShareProjectClick(projectId: Long) { + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } + if (projectId != 0L && proj?.isShareable() == true) viewModel.showShareDialogByProjectId = projectId + else showToast(this@BillsListViewActivity, getString(R.string.share_impossible), Toast.LENGTH_LONG) + } + } + + fun onExportProjectClick(projectId: Long) { + if (projectId == 0L) return + lifecycleScope.launch { + contentToExport = withContext(Dispatchers.IO) { ExportUtil.createExportContent(db, projectId) } + val fileName = withContext(Dispatchers.IO) { ExportUtil.createExportFileName(db, projectId) } + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/csv" + putExtra(Intent.EXTRA_TITLE, fileName) + } + saveFileLauncher.launch(intent) + } + } + + private fun saveToFileUri(content: String, fileUri: Uri) { + try { + contentResolver.openOutputStream(fileUri)?.use { outputStream -> + outputStream.writer().use { it.write(content) } + } + showToast(this,getString(R.string.file_saved_success, fileUri.lastPathSegment?.replace(Environment.getExternalStorageDirectory().toString(), ""))) + } catch (e: IOException) { + Log.e("Exception", "File write failed: $e") + showToast(this,e.toString()) + } + } + + private fun addProject() { + lifecycleScope.launch { + var defaultNcUrl = if (CowspentServerSyncHelper.isNextcloudAccountConfigured(this@BillsListViewActivity)) { + CowspentServerSyncHelper.getNextcloudAccountServerUrl(this@BillsListViewActivity) + } else "https://mynextcloud.org" + + val dbProjects = withContext(Dispatchers.IO) { db.projects } + for (project in dbProjects) { + val url = project.serverUrl + if (!url.isNullOrEmpty() && url.contains("/index.php/apps/cospend")) { + defaultNcUrl = url.replace("/index.php/apps/cospend", "") + break + } + } + val intent = Intent(applicationContext, NewProjectActivity::class.java).apply { + putExtra(NewProjectActivity.PARAM_DEFAULT_NC_URL, defaultNcUrl) + } + addProjectLauncher.launch(intent) + } + } + + private fun setSelectedProject(projectId: Long) { + val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + preferences.edit { putLong("selected_project", projectId) } + + lifecycleScope.launch { + val (proj, members, memberBalances) = withContext(Dispatchers.IO) { + var proj = db.getProject(projectId) + if (proj == null) { + val dbProjects = db.projects + if (dbProjects.isNotEmpty()) { + proj = dbProjects[0] + preferences.edit { putLong("selected_project", proj.id) } + } else { + return@withContext Triple(null, emptyList(), emptyMap()) + } + } + + val members = db.getMembersOfProject(proj.id, null) + val bills = db.getBillsOfProject(proj.id) + val balances = HashMap() + SupportUtil.getStats( + members, bills, + mutableMapOf(), balances, mutableMapOf(), mutableMapOf(), + -1000, -1000, null, null + ) + Triple(proj, members, balances) + } + + if (proj == null) { + viewModel.selectedProjectId = 0L + return@launch + } + + if (viewModel.selectedProjectId != proj.id) { + viewModel.selectedMemberId = null + } + viewModel.selectedProjectId = proj.id + viewModel.members = members + viewModel.memberBalances = memberBalances + + updateLastSyncText(proj) + } + } + + private fun updateLastSyncText(proj: DBProject?) { + if (proj == null || proj.isLocal) { + viewModel.lastSyncText = "" + } else { + val lastSyncTimestamp = proj.lastSyncedTimestamp ?: 0 + val cal = Calendar.getInstance().apply { timeInMillis = lastSyncTimestamp * 1000 } + val text = getString(R.string.drawer_last_sync_text, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)) + viewModel.lastSyncText = text + } + } + + fun refreshLists() = refreshLists(false) + + override fun refreshLists(scrollToTop: Boolean) { + val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + + lifecycleScope.launch { + val (projId, projName) = withContext(Dispatchers.IO) { + if (selectedProjectId != 0L) { + db.getProject(selectedProjectId)?.let { + it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name) + } ?: (0L to "") + } else { + 0L to "" + } + } + + val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name) + + setSelectedProject(selectedProjectId) + viewModel.title = title + val query = viewModel.searchQuery.ifEmpty { null } + + val (ljItems, memberCount) = withContext(Dispatchers.IO) { + val db = CowspentSQLiteOpenHelper.getInstance(applicationContext) + val billList: List = if (projId != 0L) { + db.searchBills(query, projId) + } else { + ArrayList() + } + + val bills = billList.filter { + val mid = viewModel.selectedMemberId + mid == null || mid == it.payerId || it.billOwersIds.contains(mid) + } + + viewModel.hasUnlabeledBills = bills.any { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED } + + val projectMembers = db.getMembersOfProject(projId, null) + val memberMap = projectMembers.associateBy { it.id } + + val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.remoteId } + val projectCategories = db.getCategories(projId).associateBy { it.remoteId } + + BillFormatter.formatBills( + bills, + memberMap, + projectCategories, + projectPaymentModes + ) + + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + val itemList = BillsListUtils.groupAndSectionBills( + bills, + memberMap, + sdf, + applicationContext + ) + + itemList to projectMembers.size + } + + viewModel.showNoProjects = false + viewModel.showNoMembers = false + viewModel.showNoBills = false + + when { + memberCount == 0 -> { + viewModel.showNoMembers = true + } + ljItems.isEmpty() -> { + viewModel.showNoBills = true + viewModel.bills = emptyList() + } + else -> { + viewModel.bills = ljItems + } + } + } + } + + override fun onNewIntent(intent: Intent) { + if (Intent.ACTION_SEARCH == intent.action) { + viewModel.searchQuery = intent.getStringExtra(SearchManager.QUERY) ?: "" + } + super.onNewIntent(intent) + } + + private fun duplicateBill(billId: Long) { + val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + if (selectedProjectId != 0L) { + lifecycleScope.launch { + val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) } + if (members.isEmpty()) { + showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member)) + } else { + val projType = withContext(Dispatchers.IO) { db.getProject(selectedProjectId)?.type?.id } + val intent = Intent(applicationContext, EditBillActivity::class.java).apply { + putExtra(EditBillActivity.PARAM_PROJECT_ID, selectedProjectId) + putExtra(EditBillActivity.PARAM_PROJECT_TYPE, projType) + putExtra(EditBillActivity.PARAM_BILL_ID_TO_DUPLICATE, billId) + } + createBillLauncher.launch(intent) + } + } + } + } + + private fun showDialog(msg: String, title: String, icon: ImageVector) { + viewModel.showDialog( + title = title, + message = msg, + positiveText = getString(android.R.string.ok), + icon = icon + ) + } + + private fun updateUsernameInDrawer() { + if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) { + val text = getString(R.string.drawer_no_account) + viewModel.accountName = text + updateAvatarInDrawer(false) + } else { + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + val (user, server) = if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + try { + val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(this) + ssoAccount.userId to ssoAccount.url.replace(Regex("/+$"), "").replace(Regex("^https?://"), "") + } catch (_: Exception) { "error" to "error" } + } else { + preferences.getString(AccountActivity.SETTINGS_USERNAME, "") to preferences.getString( + AccountActivity.SETTINGS_URL, "")?.replace(Regex("/+$"), "")?.replace(Regex("^https?://"), "") + } + val text = "$user@$server" + viewModel.accountName = text + updateAvatarInDrawer(true) + } + } + + private fun updateAvatarInDrawer(isConfigured: Boolean) { + if (isConfigured) { + val avatarB64 = PreferenceManager.getDefaultSharedPreferences(this).getString(getString(R.string.pref_key_avatar), "") + if (!avatarB64.isNullOrEmpty()) { + try { + val bytes = Base64.decode(avatarB64, Base64.DEFAULT) + viewModel.userAvatar = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } catch (_: Exception) { + viewModel.userAvatar = null + } + } else { + viewModel.userAvatar = null + } + } else { + viewModel.userAvatar = null + } + } + + private fun synchronize(manual: Boolean = false) { + val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false) + if (offlineMode && !manual) { + return + } + + if (db.cowspentServerSyncHelper.isSyncPossible) { + viewModel.isRefreshing = true + val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) + if (selectedProjectId != 0L) { + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } + if (proj != null && !proj.isLocal) { + db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) + db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId) + } else viewModel.isRefreshing = false + } + } else viewModel.isRefreshing = false + if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { + db.cowspentServerSyncHelper.runAccountProjectsSync() + } + } + } + + private fun registerBroadcastReceiver() { + val filter = IntentFilter().apply { + addAction(MainConstants.BROADCAST_PROJECT_SYNC_FAILED) + addAction(MainConstants.BROADCAST_PROJECT_SYNCED) + addAction(MainConstants.BROADCAST_SYNC_PROJECT) + addAction(MainConstants.BROADCAST_NETWORK_AVAILABLE) + addAction(MainConstants.BROADCAST_NETWORK_UNAVAILABLE) + addAction(MainConstants.BROADCAST_AVATAR_UPDATED) + addAction(MainConstants.BROADCAST_SSO_TOKEN_MISMATCH) + addAction(MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNC_FAILED) + addAction(MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNCED) + } + ContextCompat.registerReceiver(this, mBroadcastReceiver, filter, ContextCompat.RECEIVER_EXPORTED) + } + + private val mBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action == null) return + when (intent.action) { + MainConstants.BROADCAST_PROJECT_SYNC_FAILED -> { + val errorMessage = intent.getStringExtra(MainConstants.BROADCAST_ERROR_MESSAGE) + val projectId = intent.getLongExtra(MainConstants.BROADCAST_PROJECT_ID, 0) + if (projectId != 0L) { + lifecycleScope.launch { + val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch + viewModel.showDialog( + title = getString(R.string.sync_error_dialog_title), + message = getString(R.string.sync_error_dialog_full_content, project.name, errorMessage), + positiveText = getString(R.string.simple_close), + icon = Icons.Default.Sync + ) + } + } + } + MainConstants.BROADCAST_PROJECT_SYNCED -> { + setupDrawerProjects() + refreshLists() + } + MainConstants.BROADCAST_SYNC_PROJECT -> { + synchronize() + } + MainConstants.BROADCAST_NETWORK_AVAILABLE -> { + } + MainConstants.BROADCAST_NETWORK_UNAVAILABLE -> { + } + MainConstants.BROADCAST_SSO_TOKEN_MISMATCH -> { + viewModel.showDialog( + title = getString(R.string.sync_error_dialog_title), + message = getString(R.string.error_token_mismatch), + positiveText = getString(R.string.simple_close), + icon = Icons.Default.Sync + ) + } + MainConstants.BROADCAST_AVATAR_UPDATED -> { + val memberId = intent.getLongExtra(MainConstants.BROADCAST_AVATAR_UPDATED_MEMBER, 0) + if (memberId == 0L) updateAvatarInDrawer(true) else refreshLists() + } + MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNCED -> { + setupDrawerProjects() + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + lifecycleScope.launch { + val dbProjects = withContext(Dispatchers.IO) { db.projects } + if (prefs.getLong("selected_project", 0) == 0L && dbProjects.isNotEmpty()) { + setSelectedProject(dbProjects[0].id) + refreshLists() + if (db.cowspentServerSyncHelper.isSyncPossible) { + db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) + synchronize() + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewModel.kt new file mode 100644 index 0000000..caafc3f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewModel.kt @@ -0,0 +1,77 @@ +package net.helcel.cowspent.android.main + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.Item +import net.helcel.cowspent.android.helper.DialogState + +import androidx.compose.ui.graphics.vector.ImageVector + +class BillsListViewModel : ViewModel() { + var projects by mutableStateOf>(emptyList()) + var members by mutableStateOf>(emptyList()) + var memberBalances by mutableStateOf>(emptyMap()) + var selectedProjectId by mutableLongStateOf(0L) + var selectedMemberId by mutableStateOf(null) + var bills by mutableStateOf>(emptyList()) + var isRefreshing by mutableStateOf(false) + var searchQuery by mutableStateOf("") + var title by mutableStateOf("") + var accountName by mutableStateOf("") + var userAvatar by mutableStateOf(null) + var lastSyncText by mutableStateOf("") + + var showNoProjects by mutableStateOf(false) + var showNoMembers by mutableStateOf(false) + var showNoBills by mutableStateOf(false) + var hasUnlabeledBills by mutableStateOf(false) + + var dialogState by mutableStateOf(null) + + var showProjectOptionsDialogByProjectId by mutableStateOf(null) + var showSettlementDialogByProjectId by mutableStateOf(null) + var showStatisticsDialogByProjectId by mutableStateOf(null) + var showMemberManagementDialogByProjectId by mutableStateOf(null) + var showAddMemberDialogByProjectId by mutableStateOf(null) + var showEditMemberDialogByProjectId by mutableStateOf(null) + var showShareDialogByProjectId by mutableStateOf(null) + + fun showDialog( + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + items: List? = null, + itemIcons: List? = null, + positiveText: String? = null, + negativeText: String? = null, + neutralText: String? = null, + onConfirm: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onNeutral: (() -> Unit)? = null, + onItemSelected: ((Int) -> Unit)? = null + ) { + dialogState = DialogState( + title = title, + message = message, + icon = icon, + items = items, + itemIcons = itemIcons, + positiveText = positiveText, + negativeText = negativeText, + neutralText = neutralText, + onConfirm = onConfirm, + onCancel = onCancel, + onNeutral = onNeutral, + onItemSelected = onItemSelected + ) + } + + fun dismissDialog() { + dialogState = null + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/MainConstants.kt b/app/src/main/java/net/helcel/cowspent/android/main/MainConstants.kt new file mode 100644 index 0000000..5d5b221 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/main/MainConstants.kt @@ -0,0 +1,36 @@ +package net.helcel.cowspent.android.main + +@Suppress("unused") +object MainConstants { + const val BROADCAST_EXTRA_PARAM = "net.helcel.cowspent.broadcast_extra_param" + const val BROADCAST_ERROR_MESSAGE = "net.helcel.cowspent.broadcast_error_message" + const val BROADCAST_PROJECT_ID = "net.helcel.cowspent.broadcast_project_id" + const val BROADCAST_ACCOUNT_PROJECTS_SYNC_FAILED = "net.helcel.cowspent.broadcast_acc_proj_failed" + const val BROADCAST_SSO_TOKEN_MISMATCH = "net.helcel.cowspent.broadcast.token_mismatch" + const val BROADCAST_ACCOUNT_PROJECTS_SYNCED = "net.helcel.cowspent.broadcast.broadcast_acc_proj_synced" + + const val BROADCAST_PROJECT_SYNC_FAILED = "net.helcel.cowspent.broadcast.project_sync_failed" + const val BROADCAST_PROJECT_SYNCED = "net.helcel.cowspent.broadcast.project_synced" + const val BROADCAST_SYNC_PROJECT = "net.helcel.cowspent.broadcast.sync_project" + const val BROADCAST_NETWORK_AVAILABLE = "net.helcel.cowspent.broadcast.network_available" + const val BROADCAST_NETWORK_UNAVAILABLE = "net.helcel.cowspent.broadcast.network_unavailable" + const val BROADCAST_AVATAR_UPDATED = "net.helcel.cowspent.broadcast.avatar_updated" + const val BROADCAST_AVATAR_UPDATED_MEMBER = "net.helcel.cowspent.broadcast.avatar_updated_for_member" + + const val MAIN_CHANNEL_ID = 1234567890 + + const val PARAM_DIALOG_CONTENT = "net.helcel.cowspent.PARAM_DIALOG_CONTENT" + const val PARAM_PROJECT_TO_SELECT = "net.helcel.cowspent.PARAM_PROJECT_TO_SELECT" + + const val SAVED_BILL_ID = "net.helcel.cowspent.saved_bill_id" + const val CREATED_PROJECT = "net.helcel.cowspent.created_project" + const val ADDED_PROJECT = "net.helcel.cowspent.added_project" + const val EDITED_PROJECT = "net.helcel.cowspent.edited_project" + const val DELETED_PROJECT = "net.helcel.cowspent.deleted_project" + const val DELETED_BILL = "net.helcel.cowspent.deleted_bill" + const val BILL_TO_DUPLICATE = "net.helcel.cowspent.bill_to_duplicate" + + const val KEY_QR_CODE = "net.helcel.cowspent.android.helper.key_qr_code" + + const val CREDENTIALS_CHANGED = "net.helcel.cowspent.CREDENTIALS_CHANGED" +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt new file mode 100644 index 0000000..9ef41cc --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt @@ -0,0 +1,175 @@ +package net.helcel.cowspent.android.project + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import com.opencsv.CSVReader +import net.helcel.cowspent.R +import net.helcel.cowspent.model.* +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import java.io.InputStreamReader +import java.text.SimpleDateFormat +import java.util.* + +object ProjectImportHelper { + + @SuppressLint("Range") + fun getFileName(contentResolver: ContentResolver, uri: Uri): String { + var result: String? = null + if (uri.scheme == "content") { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + } + if (result == null) { + result = uri.path?.let { p -> + val cut = p.lastIndexOf('/') + if (cut != -1) p.substring(cut + 1) else p + } + } + return result ?: "project.csv" + } + + fun importFromFile( + context: Context, + db: CowspentSQLiteOpenHelper, + fileUri: Uri, + onSuccess: (Long) -> Unit, + onError: (String) -> Unit + ) { + val contentResolver = context.contentResolver + try { + val projectRemoteId = getFileName(contentResolver, fileUri).replace("\\.csv$".toRegex(), "") + val inputStream = contentResolver.openInputStream(fileUri) ?: return + val reader = CSVReader(InputStreamReader(inputStream)) + + var previousLineEmpty = false + var currentSection: String? = null + var row = 0 + var mainCurrencyName: String? = null + val columns = mutableMapOf() + val paymentModes = mutableListOf() + val categories = mutableListOf() + val currencies = mutableListOf() + val bills = mutableListOf() + val membersActive = mutableMapOf() + val membersWeight = mutableMapOf() + val billRemoteIdToPayerName = mutableMapOf() + val billRemoteIdToOwerStr = mutableMapOf() + + var nextLine: Array? + while (reader.readNext().also { nextLine = it } != null) { + val line = nextLine!! + val allFieldsEmpty = line.all { it.isEmpty() } + + if (allFieldsEmpty) { + previousLineEmpty = true + } else if (row == 0 || previousLineEmpty) { + previousLineEmpty = false + columns.clear() + line.forEachIndexed { index, s -> columns[s] = index } + + currentSection = when { + columns.containsKey("what") && columns.containsKey("amount") -> "bills" + columns.containsKey("categoryid") && columns.containsKey("categoryname") -> "categories" + columns.containsKey("exchange_rate") && columns.containsKey("currencyname") -> "currencies" + else -> { + onError(context.getString(R.string.import_error_header, row)) + return + } + } + } else { + previousLineEmpty = false + when (currentSection) { + "categories" -> { + categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!])) + } + "paymentmodes" -> { + paymentModes.add(DBPaymentMode(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!])) + } + "currencies" -> { + val name = line[columns["currencyname"]!!] + val rate = line[columns["exchange_rate"]!!].toDouble() + if (rate == 1.0) mainCurrencyName = name + currencies.add(DBCurrency(0, 0, 0, name, rate, DBBill.STATE_OK)) + } + "bills" -> { + val what = if (columns.containsKey("what")) line[columns["what"]!!] else "" + val comment = if (columns.containsKey("comment")) line[columns["comment"]!!] else "" + val amount = if (columns.containsKey("amount")) line[columns["amount"]!!].toDouble() else 0.0 + val timestamp: Long = when { + columns.containsKey("timestamp") -> line[columns["timestamp"]!!].toLong() + columns.containsKey("date") -> { + try { + SimpleDateFormat("yyyy-MM-dd", Locale.ROOT).parse(line[columns["date"]!!])!!.time / 1000 + } catch (_: Exception) { + onError(context.getString(R.string.import_error_date, row)) + return + } + } + else -> 0 + } + val payerName = if (columns.containsKey("payer_name")) line[columns["payer_name"]!!] else "" + val payerWeight = if (columns.containsKey("payer_weight")) line[columns["payer_weight"]!!].toDouble() else 1.0 + val owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else "" + val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1" + val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toInt() else 0 + val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toInt() else 0 + val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null + + membersActive[payerName] = payerActive + membersWeight[payerName] = payerWeight + + if (owersStr.trim().isEmpty()) { + onError(context.getString(R.string.import_error_owers, row)) + return + } + + if (what != "deleteMeIfYouWant") { + billRemoteIdToOwerStr[row.toLong()] = owersStr + val owersArray = owersStr.split(", ").filter { it.isNotEmpty() } + for (ower in owersArray) { + if (!membersWeight.containsKey(ower.trim())) { + membersWeight[ower.trim()] = 1.0 + } + } + bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, pmId)) + billRemoteIdToPayerName[row.toLong()] = payerName + } + } + } + } + row++ + } + + val memberNameToId = mutableMapOf() + val pid = db.addProject(DBProject(0, projectRemoteId, "", projectRemoteId, null, null, null, ProjectType.LOCAL, 0L, mainCurrencyName, false, DBProject.ACCESS_LEVEL_UNKNOWN, null)) + + paymentModes.forEach { db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color)) } + categories.forEach { db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color)) } + currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) } + + membersWeight.keys.forEach { mName -> + memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, null, null, null, null, null)) + } + + bills.forEach { b -> + val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L + val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, b.categoryRemoteId, b.comment, b.paymentModeRemoteId)) + billRemoteIdToOwerStr[b.remoteId]?.split(", ")?.filter { it.isNotEmpty() }?.forEach { ower -> + memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) } + } + } + onSuccess(pid) + + } catch (e: Exception) { + Log.e("Import", "Error importing", e) + onError("Import failed: ${e.message}") + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt new file mode 100644 index 0000000..9d39a8b --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt @@ -0,0 +1,156 @@ +package net.helcel.cowspent.android.project + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R + +import net.helcel.cowspent.model.DBProject + +@Composable +fun ProjectOptionsDialogContent( + onEditProject: () -> Unit, + onRemoveProject: () -> Unit, + onManageMembers: () -> Unit, + onManageCurrencies: () -> Unit, + onStatistics: () -> Unit, + onSettle: () -> Unit, + onShareProject: () -> Unit, + onExportProject: () -> Unit, + onDismiss: () -> Unit, + isArchived: Boolean = false, + accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN, + isShareable: Boolean = true +) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.choose_project_management_action), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 8.dp) + ) + + val options = mutableListOf() + val isMaintainer = accessLevel >= DBProject.ACCESS_LEVEL_MAINTAINER || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN + val isParticipant = accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN + + if (!isArchived && isMaintainer) { + options.add(ProjectOption(stringResource(R.string.action_edit_project), Icons.Default.Edit, onEditProject)) + } + options.add(ProjectOption(stringResource(R.string.fab_rm_project), Icons.Default.Delete, onRemoveProject)) + if (!isArchived && isMaintainer) { + options.add(ProjectOption(stringResource(R.string.fab_manage_members), Icons.Default.Group, onManageMembers)) + options.add(ProjectOption(stringResource(R.string.fab_manage_currencies), Icons.Default.MonetizationOn, onManageCurrencies)) + } + options.add(ProjectOption(stringResource(R.string.fab_statistics), Icons.Default.BarChart, onStatistics)) + if (!isArchived && isParticipant) { + options.add(ProjectOption(stringResource(R.string.fab_settle), Icons.Default.Handshake, onSettle)) + } + if (isShareable && isParticipant) { + options.add(ProjectOption(stringResource(R.string.action_share_project), Icons.Default.Share, onShareProject)) + } + options.add(ProjectOption(stringResource(R.string.fab_export_project), Icons.Default.Download, onExportProject)) + + // Simple 2-column grid using Rows + for (i in options.indices step 2) { + Row(modifier = Modifier.fillMaxWidth()) { + ProjectOptionItem( + option = options[i], + modifier = Modifier.weight(1f) + ) + if (i + 1 < options.size) { + ProjectOptionItem( + option = options[i + 1], + modifier = Modifier.weight(1f) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + } + } +} + +data class ProjectOption( + val title: String, + val icon: ImageVector, + val onClick: () -> Unit +) + +@Composable +fun ProjectOptionItem( + option: ProjectOption, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clickable { option.onClick() } + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = option.icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colors.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = option.title, + style = MaterialTheme.typography.caption, + textAlign = TextAlign.Center, + maxLines = 2, + lineHeight = 14.sp + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ProjectOptionsDialogPreview() { + MaterialTheme { + ProjectOptionsDialogContent( + onEditProject = {}, + onRemoveProject = {}, + onManageMembers = {}, + onManageCurrencies = {}, + onStatistics = {}, + onSettle = {}, + onShareProject = {}, + onExportProject = {}, + onDismiss = {}, + isArchived = false, + accessLevel = DBProject.ACCESS_LEVEL_ADMIN, + isShareable = true + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt new file mode 100644 index 0000000..ade833d --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt @@ -0,0 +1,316 @@ +package net.helcel.cowspent.android.project + +import android.content.Intent +import android.graphics.Bitmap +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.google.zxing.WriterException +import net.helcel.cowspent.R +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType +import net.helcel.cowspent.util.ColorUtils + +@Composable +fun ProjectShareDialogContent( + proj: DBProject, + onShare: (String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val qrCodeLinkWarn = stringResource(R.string.qrcode_link_open_attempt_warning) + + val shareUrl = remember { proj.getShareUrl() } + val publicWebUrl = remember { proj.getPublicWebUrl() } + + val qrBitmap = remember(shareUrl) { + try { + ColorUtils.encodeAsBitmap(shareUrl) + } catch (_: WriterException) { + null + } + } + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 650.dp) + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 8.dp) + ) { + Icon( + Icons.Default.Share, + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.share_dialog_title), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Bold + ) + } + + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + ShareCard( + title = stringResource(R.string.share_project_public_url_title), + url = publicWebUrl, + description = stringResource(R.string.share_project_public_url_dialog_message), + icon = Icons.Default.Link, + onUrlClick = { + val i = Intent(Intent.ACTION_VIEW).apply { + data = publicWebUrl.toUri() + } + context.startActivity(i) + }, + onCopyClick = { + clipboardManager.setText(AnnotatedString(publicWebUrl)) + Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) + + ShareCard( + title = stringResource(R.string.share_project_public_qrcode_title), + url = shareUrl, + description = stringResource(R.string.share_project_dialog_message), + icon = Icons.Default.QrCode, + qrBitmap = qrBitmap, + onUrlClick = { + Toast.makeText(context, qrCodeLinkWarn, Toast.LENGTH_SHORT).show() + }, + onCopyClick = { + clipboardManager.setText(AnnotatedString(shareUrl)) + Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + DialogActions( + onShare = { onShare(shareUrl) }, + onDismiss = onDismiss + ) + } + } +} + +@Composable +private fun ShareCard( + title: String, + url: String, + description: String, + icon: ImageVector, + onUrlClick: () -> Unit, + onCopyClick: () -> Unit, + qrBitmap: Bitmap? = null +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = colorResource(R.color.fg_default_low).copy(alpha = 0.04f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.subtitle2, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f) + ) + } + + if (qrBitmap != null) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier.size(128.dp) + .clip(MaterialTheme.shapes.small) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colors.surface, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .clickable { onUrlClick() } + .padding(start = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = url, + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.primary, + style = MaterialTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium + ) + IconButton(onClick = onCopyClick) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(18.dp), + tint = colorResource(R.color.fg_default_low) + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text( + text = description, + style = MaterialTheme.typography.caption, + textAlign = TextAlign.Center, + lineHeight = 16.sp, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + } + } +} + +@Composable +private fun DialogActions( + onShare: () -> Unit, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onDismiss, + shape = MaterialTheme.shapes.small + ) { + Text( + text = stringResource(R.string.simple_ok).uppercase(), + style = MaterialTheme.typography.button, + fontWeight = FontWeight.SemiBold + ) + } + Button( + onClick = onShare, + shape = MaterialTheme.shapes.small, + elevation = null + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.simple_share_share).uppercase(), + style = MaterialTheme.typography.button, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ProjectShareDialogContentPreview() { + MaterialTheme { + ProjectShareDialogContent( + proj = DBProject( + 1, "Vacation", "", "vacation", null, null, null, + ProjectType.LOCAL, 0L, null, false, 0, null + ), + onShare = {}, + onDismiss = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt new file mode 100644 index 0000000..92f58a6 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt @@ -0,0 +1,347 @@ +package net.helcel.cowspent.android.project.create + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Patterns +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.model.* +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.* +import androidx.core.net.toUri +import androidx.core.content.edit +import net.helcel.cowspent.model.ProjectType +import net.helcel.cowspent.android.main.MainConstants +import net.helcel.cowspent.android.helper.QrCodeScannerActivity +import net.helcel.cowspent.android.project.ProjectImportHelper + +class NewProjectActivity : AppCompatActivity() { + + private val viewModel: NewProjectViewModel by viewModels() + private lateinit var db: CowspentSQLiteOpenHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + db = CowspentSQLiteOpenHelper.getInstance(this) + + if (savedInstanceState == null) { + handleIntent(intent) + } + + setContent { + ThemeUtils.CowspentTheme { + NewProjectScreen( + viewModel = viewModel, + onScanQrCode = { + val createIntent = Intent(this, QrCodeScannerActivity::class.java) + scanQRCodeLauncher.launch(createIntent) + }, + onImportFile = { + val intent = Intent() + .setType("*/*") + .setAction(Intent.ACTION_GET_CONTENT) + importFileLauncher.launch(Intent.createChooser(intent, "Select a file")) + }, + onChooseFromNextcloud = { + chooseFromNextcloud() + }, + onOkPressed = { onPressOk() }, + onBack = { finish() } + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + val defaultTypeId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_TYPE) + if (defaultTypeId != null) { + viewModel.projectType = ProjectType.getTypeById(defaultTypeId) ?: ProjectType.LOCAL + } + + val defaultNcUrl = intent.getStringExtra(PARAM_DEFAULT_NC_URL) + if (defaultNcUrl != null) { + viewModel.defaultNcUrl = defaultNcUrl + if (viewModel.projectType != ProjectType.IHATEMONEY) { + viewModel.projectUrl = defaultNcUrl + } + } + + val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID) + if (defaultProjectId != null) { + viewModel.projectId = defaultProjectId + } + + val defaultPassword = intent.getStringExtra(PARAM_DEFAULT_PROJECT_PASSWORD) + if (defaultPassword != null) { + viewModel.projectPassword = defaultPassword + } + + if (Intent.ACTION_VIEW == intent.action) { + val data = intent.data + if (data != null) { + viewModel.updateFromUri(data) + if (viewModel.isFormValid()) { + onPressOk() + } + } + } + } + + private val scanQRCodeLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.getStringExtra(MainConstants.KEY_QR_CODE)?.let { scannedUrl -> + viewModel.updateFromUri(scannedUrl.toUri()) + if (viewModel.isFormValid()) { + onPressOk() + } + } + } + } + + private val importFileLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.let { importFromFile(it) } + } + } + + private fun chooseFromNextcloud() { + lifecycleScope.launch { + val accountProjects = withContext(Dispatchers.IO) { db.accountProjects } + if (accountProjects.isEmpty()) { + showToast(getString(R.string.choose_account_project_dialog_impossible), Toast.LENGTH_LONG) + return@launch + } + + viewModel.nextcloudProjects = accountProjects + viewModel.showNextcloudProjectDialog = true + } + } + + private fun onPressOk() { + val type = viewModel.projectType + val todoCreate = viewModel.whatTodoIsCreate + val url = getFormattedUrl() + + val fakeProj = DBProject( + 0, "", "", "", url, + "", 0L, type, 0L, + null, false, DBProject.ACCESS_LEVEL_UNKNOWN, + "" + ) + + if (isValidUrl(url) && todoCreate && ProjectType.COSPEND == type && + db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj) && + !viewModel.showAuthWarningDialog // Avoid infinite loop + ) { + viewModel.showAuthWarningDialog = true + } else { + createProject() + } + } + + private fun getFormattedUrl(): String { + var url = viewModel.projectUrl.trim() + if (viewModel.projectType == ProjectType.COSPEND && !isCospendSchemeLink(url)) { + url = url.replace("/+$".toRegex(), "") + "/index.php/apps/cospend" + } + if (viewModel.projectType == ProjectType.IHATEMONEY) { + url = url.replace("/+$".toRegex(), "") + } + if (!url.startsWith("http://") && !url.startsWith("https://") && isValidUrl("https://$url")) { + url = "https://$url" + } + return url + } + + private fun isValidUrl(url: String): Boolean = Patterns.WEB_URL.matcher(url).matches() + + private fun isCospendSchemeLink(url: String): Boolean { + val data = url.toUri() + return (("cospend" == data.scheme || "cospend+http" == data.scheme || + "cowspent" == data.scheme || "cowspent+http" == data.scheme || + "ihatemoney" == data.scheme || "ihatemoney+http" == data.scheme) + && data.pathSegments.size >= 2) + } + + private fun createProject() { + val isCospendScheme = isCospendSchemeLink(getFormattedUrl()) + val rid = viewModel.projectId + if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) { + showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG) + return + } + + if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) { + if (!isValidUrl(getFormattedUrl())) { + showToast("Invalid URL", Toast.LENGTH_SHORT) + return + } + val passwordRequired = !viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY + if (passwordRequired && viewModel.projectPassword.isEmpty()) { + showToast("Invalid password", Toast.LENGTH_SHORT) + return + } + } + + if (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.LOCAL) { + if (viewModel.projectType == ProjectType.LOCAL) { + val pid = saveLocalProject() + close(pid, false) + } else { + saveRemoteProject(false) + } + } else { + if (viewModel.projectName.isEmpty()) { + showToast("Invalid project name", Toast.LENGTH_SHORT) + return + } + if (!SupportUtil.isValidEmail(viewModel.projectEmail)) { + showToast("Invalid email", Toast.LENGTH_SHORT) + return + } + + viewModel.isCreatingRemoteProject = true + + if (!db.cowspentServerSyncHelper.createRemoteProject( + viewModel.projectId, viewModel.projectName, + viewModel.projectEmail, viewModel.projectPassword, getFormattedUrl(), viewModel.projectType, createRemoteCallBack + ) + ) { + viewModel.isCreatingRemoteProject = false + } + } + } + + private fun saveLocalProject(): Long { + val newProject = DBProject( + 0, viewModel.projectId, "", viewModel.projectId, null, + null, null, viewModel.projectType, 0L, + null, false, DBProject.ACCESS_LEVEL_UNKNOWN, + null + ) + return addProjectToDb(newProject) + } + + private fun saveRemoteProject(ignorePassword: Boolean) { + val newProject = getProjectFromFields(ignorePassword) + if (!db.cowspentServerSyncHelper.getRemoteProjectInfo(newProject, getRemoteInfoCallBack)) { + showToast(getString(R.string.error_no_network), Toast.LENGTH_LONG) + } + } + + private fun getProjectFromFields(ignorePassword: Boolean): DBProject { + var remoteId = viewModel.projectId + var url = getFormattedUrl() + var password = if (ignorePassword) "" else viewModel.projectPassword + + if (isCospendSchemeLink(url)) { + val data = url.toUri() + password = if (ignorePassword) "" else (data.lastPathSegment ?: "") + remoteId = data.pathSegments[data.pathSegments.size - 2] + val protocol = if (data.scheme?.endsWith("+http") == true) "http" else "https" + var path = protocol + "://" + data.host + (data.path ?: "").replace(("/$remoteId/$password$").toRegex(), "") + if (viewModel.projectType == ProjectType.COSPEND) { + path = path.replace("/+$".toRegex(), "") + "/index.php/apps/cospend" + } + url = path + } + + return DBProject( + 0, remoteId, password, viewModel.projectName, url, + viewModel.projectEmail, null, viewModel.projectType, 0L, + null, false, DBProject.ACCESS_LEVEL_UNKNOWN, + null + ) + } + + private fun addProjectToDb(project: DBProject): Long { + var pid = 0L + runBlocking { + pid = withContext(Dispatchers.IO) { db.addProject(project) } + } + PreferenceManager.getDefaultSharedPreferences(this).edit { + putLong( + "selected_project", + pid + ) + } + showToast(getString(R.string.project_added_success), Toast.LENGTH_LONG) + return pid + } + + private val getRemoteInfoCallBack = object : ICallback { + override fun onFinish() {} + override fun onFinish(result: String, message: String) { + if (message.isEmpty()) { + val pid = addProjectToDb(getProjectFromFields(false)) + close(pid, true) + } else { + viewModel.errorDialogMessage = getString(R.string.error_project_connect_check, message) + } + } + override fun onScheduled() {} + } + + private val createRemoteCallBack = object : IProjectCreationCallback { + override fun onFinish(result: String, message: String, usePrivateApi: Boolean) { + if (message.isEmpty()) { + saveRemoteProject(usePrivateApi) + } else { + viewModel.errorDialogMessage = getString(R.string.error_create_remote_project_helper, message) + viewModel.isCreatingRemoteProject = false + } + } + } + + private fun close(pid: Long, justAdded: Boolean) { + val data = Intent() + if (justAdded) { + data.putExtra(MainConstants.ADDED_PROJECT, pid) + } else { + data.putExtra(MainConstants.CREATED_PROJECT, pid) + } + setResult(RESULT_OK, data) + finish() + } + + private fun showToast(text: CharSequence?, duration: Int) { + Toast.makeText(this, text, duration).show() + } + + private fun importFromFile(fileUri: Uri) { + ProjectImportHelper.importFromFile( + this, + db, + fileUri, + onSuccess = { pid -> close(pid, false) }, + onError = { message -> showToast(message, Toast.LENGTH_LONG) } + ) + } + + companion object { + const val PARAM_DEFAULT_NC_URL = "defaultNcUrl" + const val PARAM_DEFAULT_PROJECT_ID = "defaultProjectId" + const val PARAM_DEFAULT_PROJECT_PASSWORD = "defaultProjectPassword" + const val PARAM_DEFAULT_PROJECT_TYPE = "defaultProjectType" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt new file mode 100644 index 0000000..bceb66b --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt @@ -0,0 +1,339 @@ +package net.helcel.cowspent.android.project.create + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.Title +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.model.ProjectType + +@Composable +fun NewProjectScreen( + viewModel: NewProjectViewModel, + onScanQrCode: () -> Unit, + onImportFile: () -> Unit, + onChooseFromNextcloud: () -> Unit, + onOkPressed: () -> Unit, + onBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_add_project)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + }, + floatingActionButton = { + if (viewModel.isFormValid()) { + FloatingActionButton(onClick = onOkPressed) { + Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save_bill)) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + .fillMaxSize() + ) { + // What to do + SectionRow( + label = stringResource(R.string.new_project_what_todo) + ) { + Row { + ToggleButton( + text = stringResource(R.string.todo_join_label), + selected = !viewModel.whatTodoIsCreate, + onClick = { + viewModel.whatTodoIsCreate = false + if (viewModel.projectType == ProjectType.LOCAL) + viewModel.projectType = ProjectType.COSPEND + } + ) + Spacer(modifier = Modifier.width(8.dp)) + ToggleButton( + text = stringResource(R.string.todo_create_label), + selected = viewModel.whatTodoIsCreate, + onClick = { viewModel.whatTodoIsCreate = true } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + SectionRow( + label = stringResource(R.string.new_project_where) + ) { + Row { + if (viewModel.whatTodoIsCreate) { + ToggleButton( + text = stringResource(R.string.where_local_short), + selected = viewModel.projectType == ProjectType.LOCAL, + onClick = { viewModel.projectType = ProjectType.LOCAL } + ) + Spacer(modifier = Modifier.width(8.dp)) + } + ToggleButton( + text = stringResource(R.string.where_cospend_short), + selected = viewModel.projectType == ProjectType.COSPEND, + onClick = { viewModel.projectType = ProjectType.COSPEND } + ) + Spacer(modifier = Modifier.width(8.dp)) + ToggleButton( + text = stringResource(R.string.where_ihatemoney_short), + selected = viewModel.projectType == ProjectType.IHATEMONEY, + onClick = { viewModel.projectType = ProjectType.IHATEMONEY } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + + if (viewModel.whatTodoIsCreate) { + if (viewModel.projectType == ProjectType.COSPEND) { + Button( + onClick = onChooseFromNextcloud + ) { + Text(stringResource(R.string.new_project_from_nextcloud_tooltip)) + } + } else if (viewModel.projectType == ProjectType.LOCAL) { + Button( + onClick = onImportFile + ) { + Text(stringResource(R.string.import_tooltip)) + } + } + } else { + Spacer(modifier = Modifier.width(16.dp)) + Button(onClick = onScanQrCode) { + Text(text = stringResource(R.string.scan_qrcode)) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.QrCode2, + contentDescription = null, + modifier = Modifier.size(16.dp, 16.dp), + ) + } + } + + + Spacer(modifier = Modifier.height(16.dp)) + + if (viewModel.projectType != ProjectType.LOCAL) { + OutlinedTextField( + value = viewModel.projectUrl, + onValueChange = { viewModel.projectUrl = it }, + label = { + Text( + stringResource( + if (viewModel.projectType == ProjectType.COSPEND) R.string.setting_cospend_project_url + else R.string.setting_ihatemoney_project_url + ) + ) + }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + OutlinedTextField( + value = viewModel.projectId, + onValueChange = { viewModel.projectId = it }, + label = { Text(stringResource(R.string.setting_project_id)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } + ) + + if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.projectPassword, + onValueChange = { viewModel.projectPassword = it }, + label = { Text(stringResource(R.string.setting_new_project_password)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } + ) + } + + if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.projectName, + onValueChange = { viewModel.projectName = it }, + label = { Text(stringResource(R.string.setting_new_project_name)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.projectEmail, + onValueChange = { viewModel.projectEmail = it }, + label = { Text(stringResource(R.string.setting_new_project_email)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } + ) + } + } + } + + if (viewModel.showAuthWarningDialog) { + AlertDialog( + onDismissRequest = { viewModel.showAuthWarningDialog = false }, + title = { Text(stringResource(R.string.auth_project_creation_title)) }, + text = { Text(stringResource(R.string.warning_auth_project_creation)) }, + confirmButton = { + TextButton(onClick = { + viewModel.showAuthWarningDialog = false + onOkPressed() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.showAuthWarningDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } + + if (viewModel.showNextcloudProjectDialog) { + AlertDialog( + onDismissRequest = { viewModel.showNextcloudProjectDialog = false }, + title = { Text(stringResource(R.string.choose_account_project_dialog_title)) }, + text = { + Column { + viewModel.nextcloudProjects.forEach { project -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = false, + onClick = { + viewModel.projectId = project.remoteId + viewModel.projectUrl = project.ncUrl + viewModel.showNextcloudProjectDialog = false + } + ) + .padding(16.dp) + ) { + Text(text = project.name) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { viewModel.showNextcloudProjectDialog = false }) { + Text(stringResource(R.string.simple_cancel)) + } + } + ) + } + + if (viewModel.isCreatingRemoteProject) { + AlertDialog( + onDismissRequest = { }, + title = { Text(stringResource(R.string.simple_loading)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + Spacer(modifier = Modifier.width(16.dp)) + Text(stringResource(R.string.creating_remote_project)) + } + }, + confirmButton = {} + ) + } + + if (viewModel.errorDialogMessage != null) { + AlertDialog( + onDismissRequest = { viewModel.errorDialogMessage = null }, + title = { Text(stringResource(R.string.simple_error)) }, + text = { Text(viewModel.errorDialogMessage!!) }, + confirmButton = { + TextButton(onClick = { viewModel.errorDialogMessage = null }) { + Text(stringResource(R.string.simple_ok)) + } + } + ) + } +} + +@Composable +fun SectionRow( + label: String, + content: @Composable () -> Unit +) { + Row(verticalAlignment = Alignment.Top) { + Column { + Text(label, fontSize = 12.sp, color = MaterialTheme.colors.onSurface) + content() + } + } +} + +@Composable +fun ToggleButton( + text: String, + selected: Boolean, + onClick: () -> Unit +) { + Button( + onClick = onClick, + colors = if (selected) { + ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary) + } else { + ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.surface) + } + ) { + Text( + text, + fontSize = 12.sp, + color = if (selected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface + ) + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun NewProjectScreenPreview() { + NewProjectScreen( + viewModel = NewProjectViewModel().apply { + whatTodoIsCreate = true + projectType = ProjectType.COSPEND + }, + onScanQrCode = {}, + onImportFile = {}, + onChooseFromNextcloud = {}, + onOkPressed = {}, + onBack = {} + ) +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt new file mode 100644 index 0000000..b0060c0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt @@ -0,0 +1,95 @@ +package net.helcel.cowspent.android.project.create + +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import net.helcel.cowspent.model.DBAccountProject +import net.helcel.cowspent.model.ProjectType + +class NewProjectViewModel : ViewModel() { + var whatTodoIsCreate by mutableStateOf(false) + + private var _projectType by mutableStateOf(ProjectType.COSPEND) + var projectType: ProjectType + get() = _projectType + set(value) { + _projectType = value + if (value == ProjectType.IHATEMONEY && (projectUrl.isEmpty() || projectUrl == defaultNcUrl)) { + projectUrl = "https://ihatemoney.org" + } else if (value == ProjectType.COSPEND && (projectUrl == "https://ihatemoney.org" || projectUrl.isEmpty())) { + projectUrl = defaultNcUrl + } + } + + var projectUrl by mutableStateOf("") + var defaultNcUrl by mutableStateOf("") + var projectId by mutableStateOf("") + var projectPassword by mutableStateOf("") + var projectName by mutableStateOf("") + var projectEmail by mutableStateOf("") + + var showAuthWarningDialog by mutableStateOf(false) + var showNextcloudProjectDialog by mutableStateOf(false) + var nextcloudProjects by mutableStateOf>(emptyList()) + + var isCreatingRemoteProject by mutableStateOf(false) + var errorDialogMessage by mutableStateOf(null) + + fun isFormValid(): Boolean { + if (whatTodoIsCreate) { + if (projectId.isEmpty()) return false + if (projectType != ProjectType.LOCAL) { + if (projectUrl.isEmpty()) return false + if (projectName.isEmpty()) return false + if (projectEmail.isEmpty()) return false + if (projectType == ProjectType.IHATEMONEY && projectPassword.isEmpty()) return false + } + } else { + // Join + if (projectType == ProjectType.LOCAL) return false + if (projectId.isEmpty() && !isCospendSchemeLink(projectUrl)) return false + if (projectUrl.isEmpty() && !isCospendSchemeLink(projectUrl)) return false + if (projectPassword.isEmpty() && !isCospendSchemeLink(projectUrl)) return false + } + return true + } + + private fun isCospendSchemeLink(url: String): Boolean { + return url.startsWith("cospend://") || url.startsWith("cospend+http://") || + url.startsWith("cowspent://") || url.startsWith("cowspent+http://") || + url.startsWith("ihatemoney://") || url.startsWith("ihatemoney+http://") + } + + fun updateFromUri(data: Uri) { + if ((data.scheme == "cospend" || data.scheme == "cospend+http" || + data.scheme == "cowspent" || data.scheme == "cowspent+http" || + data.scheme == "ihatemoney" || data.scheme == "ihatemoney+http") + && data.pathSegments.isNotEmpty() + ) { + val password: String + val pid: String + if (data.path!!.endsWith("/")) { + password = "" + pid = data.lastPathSegment!! + } else { + password = data.lastPathSegment!! + pid = data.pathSegments[data.pathSegments.size - 2] + } + var protocol = "https" + if (data.scheme == "cospend+http" || data.scheme == "cowspent+http" || data.scheme == "ihatemoney+http") { + protocol = "http" + } + val url = protocol + "://" + data.host + val port = if (data.port != -1) ":${data.port}" else "" + val fullUrl = "$url$port${data.path!!.replace(("/$pid/$password$").toRegex(), "")}" + + projectPassword = password + projectId = pid + projectUrl = fullUrl + projectType = if (data.scheme?.startsWith("ihatemoney") == true) ProjectType.IHATEMONEY else ProjectType.COSPEND + whatTodoIsCreate = false + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt new file mode 100644 index 0000000..a105eb8 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt @@ -0,0 +1,192 @@ +package net.helcel.cowspent.android.project.edit + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.showToast +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.ICallback +import net.helcel.cowspent.util.SupportUtil +import net.helcel.cowspent.android.main.MainConstants + + +class EditProjectActivity : AppCompatActivity() { + + private val viewModel: EditProjectViewModel by viewModels() + private lateinit var db: CowspentSQLiteOpenHelper + private lateinit var project: DBProject + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + db = CowspentSQLiteOpenHelper.getInstance(this) + + val id = intent.getLongExtra(PARAM_PROJECT_ID, 0) + if (id <= 0) { + finish() + return + } + + lifecycleScope.launch { + project = withContext(Dispatchers.IO) { db.getProject(id) }!! + viewModel.initFromProject(project) + + setContent { + ThemeUtils.CowspentTheme { + EditProjectScreen( + viewModel = viewModel, + onSave = { onSave() }, + onDeleteRemote = { onDeleteRemote() }, + onBack = { finish() } + ) + } + } + } + } + + private fun onSave() { + val currentPwd = viewModel.password + val newPwd = viewModel.newPassword + val newName = viewModel.name + val newEmail = viewModel.email + + if (newName.isEmpty()) { + showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG) + return + } + if (newEmail.isNotEmpty() && !SupportUtil.isValidEmail(newEmail)) { + showToast(this, getString(R.string.error_invalid_email), Toast.LENGTH_LONG) + return + } + + val nameChanged = newName != project.name + val emailChanged = newEmail != project.email + val pwdChanged = newPwd != project.password + val currentPwdChanged = currentPwd != project.password + + if (!nameChanged && !emailChanged && !pwdChanged && !currentPwdChanged) { + showToast(this, getString(R.string.project_edition_no_change), Toast.LENGTH_LONG) + return + } + + if (project.isLocal) { + val targetPwd = if (pwdChanged) newPwd else currentPwd + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.updateProject( + project.id, newName, newEmail, targetPwd, + null, project.type, null, + null, null, + null, null + ) + } + closeOnEdit(project.id) + } + return + } + + // Remote project + if (nameChanged || emailChanged || pwdChanged) { + // Update local password first if currentPwd was changed (user fixing credentials) + if (currentPwdChanged) { + project.password = currentPwd + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.updateProject(project.id, null, null, currentPwd, null, project.type, null, null, null, null, null) + } + } + } + + if (!db.cowspentServerSyncHelper.editRemoteProject( + project.id, + newName, + newEmail, + if (pwdChanged) newPwd else null, + null, + editCallBack + ) + ) { + showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) + } + } else { + // Only current password changed locally (fixing credentials) + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.updateProject( + project.id, null, null, currentPwd, + null, project.type, null, + null, null, + null, null + ) + } + closeOnEdit(project.id) + } + } + } + + private fun onDeleteRemote() { + viewModel.showDialog( + message = getString(R.string.confirm_delete_project_dialog_title), + positiveText = getString(R.string.simple_yes), + onConfirm = { + if (!db.cowspentServerSyncHelper.deleteRemoteProject(project.id, deleteCallBack)) { + showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) + } + }, + negativeText = getString(R.string.simple_no) + ) + } + + private val editCallBack = object : ICallback { + override fun onFinish() {} + override fun onFinish(result: String, message: String) { + if (message.isEmpty()) { + closeOnEdit(project.id) + } else { + showToast(this@EditProjectActivity, getString(R.string.error_edit_remote_project_helper, message), Toast.LENGTH_LONG) + } + } + override fun onScheduled() {} + } + + private val deleteCallBack = object : ICallback { + override fun onFinish() {} + override fun onFinish(result: String, message: String) { + if (message.isEmpty()) { + closeOnDelete(result.toLong()) + } else { + showToast(this@EditProjectActivity, getString(R.string.error_edit_remote_project_helper, message), Toast.LENGTH_LONG) + } + } + override fun onScheduled() {} + } + + private fun closeOnDelete(projId: Long) { + val data = Intent() + data.putExtra(MainConstants.DELETED_PROJECT, projId) + setResult(RESULT_OK, data) + finish() + } + + private fun closeOnEdit(projId: Long) { + val data = Intent() + data.putExtra(MainConstants.EDITED_PROJECT, projId) + setResult(RESULT_OK, data) + finish() + } + + + + companion object { + const val PARAM_PROJECT_ID = "projectId" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt new file mode 100644 index 0000000..350568b --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt @@ -0,0 +1,145 @@ +package net.helcel.cowspent.android.project.edit + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Title +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.AlertDialog + +@Composable +fun EditProjectScreen( + viewModel: EditProjectViewModel, + onSave: () -> Unit, + onDeleteRemote: () -> Unit, + onBack: () -> Unit +) { + val dialogState = viewModel.dialogState + if (dialogState != null) { + AlertDialog( + showDialog = true, + onDismissRequest = { viewModel.dismissDialog() }, + title = dialogState.title, + message = dialogState.message, + icon = dialogState.icon, + items = dialogState.items, + positiveText = dialogState.positiveText, + negativeText = dialogState.negativeText, + neutralText = dialogState.neutralText, + onConfirm = { + dialogState.onConfirm?.invoke() + viewModel.dismissDialog() + }, + onCancel = { + dialogState.onCancel?.invoke() + viewModel.dismissDialog() + }, + onNeutral = { + dialogState.onNeutral?.invoke() + viewModel.dismissDialog() + } + ) { + dialogState.onItemSelected?.invoke(it) + viewModel.dismissDialog() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.simple_edit_project)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = onDeleteRemote) { + Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.menu_delete_project_remote)) + } + }, + backgroundColor = MaterialTheme.colors.surface, + elevation = 0.dp + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onSave) { + Icon(Icons.Default.Done, contentDescription = stringResource(R.string.menu_save_project)) + } + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize() + ) { + OutlinedTextField( + value = viewModel.name, + onValueChange = { viewModel.name = it }, + label = { Text(stringResource(R.string.setting_new_project_name)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = viewModel.password, + onValueChange = { viewModel.password = it }, + label = { Text(stringResource(R.string.setting_password)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = viewModel.newPassword, + onValueChange = { viewModel.newPassword = it }, + label = { Text(stringResource(R.string.setting_new_project_password)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = viewModel.email, + onValueChange = { viewModel.email = it }, + label = { Text(stringResource(R.string.setting_new_project_email)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } + ) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview(showBackground = true) +@Composable +fun EditProjectScreenPreview() { + MaterialTheme { + EditProjectScreen( + viewModel = EditProjectViewModel().apply { + name = "My Awesome Project" + email = "user@example.com" + }, + onSave = {}, + onDeleteRemote = {}, + onBack = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt new file mode 100644 index 0000000..fac868d --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt @@ -0,0 +1,57 @@ +package net.helcel.cowspent.android.project.edit + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.lifecycle.ViewModel +import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.model.DBProject + +class EditProjectViewModel : ViewModel() { + var name by mutableStateOf("") + var password by mutableStateOf("") + var newPassword by mutableStateOf("") + var email by mutableStateOf("") + + var dialogState by mutableStateOf(null) + + fun showDialog( + title: String? = null, + message: String? = null, + icon: ImageVector? = null, + items: List? = null, + positiveText: String? = null, + negativeText: String? = null, + neutralText: String? = null, + onConfirm: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + onNeutral: (() -> Unit)? = null, + onItemSelected: ((Int) -> Unit)? = null + ) { + dialogState = DialogState( + title = title, + message = message, + icon = icon, + items = items, + positiveText = positiveText, + negativeText = negativeText, + neutralText = neutralText, + onConfirm = onConfirm, + onCancel = onCancel, + onNeutral = onNeutral, + onItemSelected = onItemSelected + ) + } + + fun dismissDialog() { + dialogState = null + } + + fun initFromProject(project: DBProject) { + name = if (project.name == "null") "" else project.name + password = project.password + newPassword = project.password + email = project.email?.let { if (it == "null") "" else it } ?: "" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt new file mode 100644 index 0000000..0cfd11d --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt @@ -0,0 +1,70 @@ +package net.helcel.cowspent.android.project.member + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import net.helcel.cowspent.R + +@Composable +fun MemberAddDialogContent( + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf("") } + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.add_member_dialog_title), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.member_edit_name)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + singleLine = true + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.simple_cancel).uppercase()) + } + TextButton(onClick = { + if (name.isNotEmpty()) { + onAdd(name) + } + }) { + Text(stringResource(R.string.simple_ok).uppercase()) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MemberAddDialogContentPreview() { + MaterialTheme { + MemberAddDialogContent(onAdd = {}, onDismiss = {}) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt new file mode 100644 index 0000000..656bf80 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt @@ -0,0 +1,208 @@ +package net.helcel.cowspent.android.project.member + +import android.graphics.Color +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.material.icons.filled.* +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.ColorPicker +import net.helcel.cowspent.android.helper.TextDrawable +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.util.ColorUtils + +@Composable +fun MemberEditDialogContent( + member: DBMember, + onSave: (String, Double, Boolean, Int?, Int?, Int?) -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(member.name) } + var weight by remember { mutableStateOf(member.weight.toString()) } + var isActivated by remember { mutableStateOf(member.isActivated) } + + val initialColor = remember { + if (member.r != null && member.g != null && member.b != null) { + Color.rgb(member.r!!, member.g!!, member.b!!) + } else { + TextDrawable.getColorFromName(member.name) + } + } + var selectedColor by remember { mutableIntStateOf(initialColor) } + var showColorPicker by remember { mutableStateOf(false) } + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(R.string.edit_member_dialog_title), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.member_edit_name)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Default.Person, contentDescription = null) + }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Weight + OutlinedTextField( + value = weight, + onValueChange = { weight = it }, + label = { Text(stringResource(R.string.member_edit_weight)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Default.LineWeight, contentDescription = null) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Activated + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { isActivated = !isActivated } + .padding(vertical = 8.dp) + ) { + Icon(Icons.Default.Block, contentDescription = null) + Spacer(modifier = Modifier.width(16.dp)) + Text(stringResource(R.string.member_edit_toggle), modifier = Modifier.weight(1f)) + Checkbox(checked = isActivated, onCheckedChange = { isActivated = it }) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Color + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Icon(Icons.Default.Palette, contentDescription = null) + Spacer(modifier = Modifier.width(16.dp)) + Text(stringResource(R.string.member_edit_color), modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .size(40.dp) + .background(androidx.compose.ui.graphics.Color(selectedColor)) + .clickable { showColorPicker = true } + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Edit, + contentDescription = null, + tint = if (ColorUtils.isLightColor(selectedColor)) androidx.compose.ui.graphics.Color.Black else androidx.compose.ui.graphics.Color.White + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Delete + Button( + onClick = onDelete, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Icon(Icons.Default.Delete, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.member_edit_delete)) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Buttons + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.simple_cancel).uppercase()) + } + TextButton(onClick = { + val w = weight.replace(',', '.').toDoubleOrNull() + if (name.isNotEmpty() && w != null) { + onSave( + name, + w, + isActivated, + Color.red(selectedColor), + Color.green(selectedColor), + Color.blue(selectedColor) + ) + } + }) { + Text(stringResource(R.string.simple_ok).uppercase()) + } + } + } + } + + if (showColorPicker) { + AlertDialog( + onDismissRequest = { showColorPicker = false }, + title = { Text(stringResource(R.string.settings_colorpicker_title)) }, + text = { + ColorPicker(initialColor = selectedColor) { + selectedColor = it + } + }, + confirmButton = { + TextButton(onClick = { showColorPicker = false }) { + Text(stringResource(R.string.simple_ok).uppercase()) + } + }, + dismissButton = { + TextButton(onClick = { showColorPicker = false }) { + Text(stringResource(R.string.simple_cancel).uppercase()) + } + } + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MemberEditDialogContentPreview() { + MaterialTheme { + MemberEditDialogContent( + member = DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), + onSave = { _, _, _, _, _, _ -> }, + onDelete = {}, + onDismiss = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt new file mode 100644 index 0000000..d2121f3 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt @@ -0,0 +1,132 @@ +package net.helcel.cowspent.android.project.member + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.contentColorFor +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.lazyVerticalScrollbar +import net.helcel.cowspent.model.DBMember + +@Composable +fun MemberManagementDialogContent( + members: List, + onAddMember: () -> Unit, + onEditMember: (DBMember) -> Unit, + onDismiss: () -> Unit +) { + val listState = rememberLazyListState() + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.fab_manage_members), + style = MaterialTheme.typography.h6 + ) + IconButton(onClick = onAddMember) { + Icon(Icons.Default.Add,modifier=Modifier.size(32.dp), + contentDescription = stringResource(R.string.fab_add_member)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f, fill = false) + .lazyVerticalScrollbar(listState) + ) { + items(members) { member -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEditMember(member) } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + UserAvatar( + name = member.name, + r = member.r, + g = member.g, + b = member.b, + avatar = member.avatar, + disabled = !member.isActivated, + size = 40.dp + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = member.name, + style = MaterialTheme.typography.body1 + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.simple_close).uppercase()) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MemberManagementDialogContentPreview() { + MaterialTheme { + MemberManagementDialogContent( + members = listOf( + DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null), + DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null), + DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null) + ), + onAddMember = {}, + onEditMember = {}, + onDismiss = {} + ) + } +} + diff --git a/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt new file mode 100644 index 0000000..bcb6f04 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt @@ -0,0 +1,401 @@ +package net.helcel.cowspent.android.project.settle + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.contentColorFor +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.CenterFocusStrong +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.Transaction +import net.helcel.cowspent.model.UserItem +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.SupportUtil +import net.helcel.cowspent.util.SupportUtil.SETTLE_OPTIMAL +import net.helcel.cowspent.util.SupportUtil.settleBills + +@Composable +fun ProjectSettlementDialogContent( + proj: DBProject, + db: CowspentSQLiteOpenHelper, + onSettleBills: (List) -> Unit, + onShare: (List, Map) -> Unit, + onDismiss: () -> Unit +) { + val centerNoneStr = stringResource(R.string.center_none) + val memberList = remember(proj.id) { db.getMembersOfProject(proj.id, null) } + val userList = remember(memberList, centerNoneStr) { + buildList { + add(UserItem(SETTLE_OPTIMAL, centerNoneStr)) + addAll(memberList.map { UserItem(it.id, it.name) }) + } + } + + val membersBalance = remember(proj.id) { + val balance = mutableMapOf() + SupportUtil.getStatsOfProject( + proj.id, db, + mutableMapOf(), balance, mutableMapOf(), mutableMapOf(), + -1000, -1000, null, null + ) + balance + } + + val membersSortedByName = remember(proj.id) { + db.getMembersOfProject(proj.id, CowspentSQLiteOpenHelper.key_name) + } + + val memberIdToName = remember(membersSortedByName) { + membersSortedByName.associate { it.id to it.name } + } + + var selectedMemberId by remember(userList) { + mutableLongStateOf(userList.firstOrNull()?.id ?: SETTLE_OPTIMAL) + } + + val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) { + settleBills(membersSortedByName, membersBalance, selectedMemberId) + } + + ProjectSettlementUI( + transactions = transactions, + userList = userList, + selectedMemberId = selectedMemberId, + memberIdToName = memberIdToName, + onMemberSelected = { selectedMemberId = it }, + onSettleBills = onSettleBills, + onShare = onShare, + onDismiss = onDismiss + ) +} + +@Composable +fun ProjectSettlementUI( + transactions: List, + userList: List, + selectedMemberId: Long, + memberIdToName: Map, + onMemberSelected: (Long) -> Unit, + onSettleBills: (List) -> Unit, + onShare: (List, Map) -> Unit, + onDismiss: () -> Unit +) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colors.surface, + contentColor = contentColorFor(MaterialTheme.colors.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.settle_dialog_title), + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (transactions.isEmpty()) { + BalancedStateMessage() + } else { + MemberSelector( + selectedMemberId = selectedMemberId, + userList = userList, + onMemberSelected = onMemberSelected + ) + + Spacer(modifier = Modifier.height(16.dp)) + + TransactionTable( + transactions = transactions, + memberIdToName = memberIdToName, + modifier = Modifier.weight(1f, fill = false) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + ActionButtons( + transactions = transactions, + memberIdToName = memberIdToName, + onShare = onShare, + onSettleBills = onSettleBills, + onDismiss = onDismiss + ) + } + } +} + +@Composable +private fun BalancedStateMessage() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.settle_dialog_balanced), + style = MaterialTheme.typography.body1 + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun MemberSelector( + selectedMemberId: Long, + userList: List, + onMemberSelected: (Long) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Surface( + onClick = { expanded = true }, + shape = MaterialTheme.shapes.medium, + color = colorResource(R.color.fg_default_low).copy(alpha = 0.05f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp) + ) { + Icon( + Icons.Default.CenterFocusStrong, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = colorResource(R.color.fg_default_low) + ) + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = userList.find { it.id == selectedMemberId }?.name ?: "", + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + tint = colorResource(R.color.fg_default_low) + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + userList.forEach { user -> + DropdownMenuItem(onClick = { + onMemberSelected(user.id) + expanded = false + }) { + Text( + text = user.name, + style = MaterialTheme.typography.body1 + ) + } + } + } + } + } +} + +@Composable +private fun TransactionTable( + transactions: List, + memberIdToName: Map, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + TableHeaderText(stringResource(R.string.settle_who), Modifier.weight(1.2f)) + TableHeaderText(stringResource(R.string.settle_to_whom), Modifier.weight(1.2f)) + TableHeaderText(stringResource(R.string.settle_how_much), Modifier.weight(1f), TextAlign.End) + } + + Divider(color = colorResource(R.color.fg_default_low).copy(alpha = 0.12f)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(transactions) { t -> + TransactionRow(t, memberIdToName) + } + } + } +} + +@Composable +private fun TableHeaderText( + text: String, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Start +) { + Text( + text = text.uppercase(), + modifier = modifier, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.overline, + color = colorResource(R.color.fg_default_low), + textAlign = textAlign, + letterSpacing = 0.8.sp + ) +} + +@Composable +private fun TransactionRow( + transaction: Transaction, + memberIdToName: Map +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = Color.Transparent + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = memberIdToName[transaction.owerMemberId] ?: "-", + modifier = Modifier.weight(1.2f), + style = MaterialTheme.typography.body2, + color = colorResource(R.color.fg_default) + ) + Text( + text = memberIdToName[transaction.receiverMemberId] ?: "-", + modifier = Modifier.weight(1.2f), + style = MaterialTheme.typography.body2, + color = colorResource(R.color.fg_default) + ) + Text( + text = SupportUtil.normalNumberFormat.format(transaction.amount), + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold, + color = colorResource(R.color.fg_default) + ) + } + } +} + +@Composable +private fun ActionButtons( + transactions: List, + memberIdToName: Map, + onShare: (List, Map) -> Unit, + onSettleBills: (List) -> Unit, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + if (transactions.isNotEmpty()) { + TextButton(onClick = { onShare(transactions, memberIdToName) }) { + Text(stringResource(R.string.simple_settle_share).uppercase()) + } + TextButton(onClick = { onSettleBills(transactions) }) { + Text(stringResource(R.string.simple_create_bills).uppercase()) + } + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.simple_ok).uppercase()) + } + } +} + +@Preview(showBackground = true, name = "With Transactions") +@Composable +fun ProjectSettlementUIPreview() { + MaterialTheme { + ProjectSettlementUI( + transactions = listOf( + Transaction(1, 2, 25.50), + Transaction(3, 1, 10.00), + Transaction(2, 3, 5.25) + ), + userList = listOf( + UserItem(SETTLE_OPTIMAL, "None (Optimal)"), + UserItem(1, "Alice"), + UserItem(2, "Bob"), + UserItem(3, "Charlie") + ), + selectedMemberId = SETTLE_OPTIMAL, + memberIdToName = mapOf(1L to "Alice", 2L to "Bob", 3L to "Charlie"), + onMemberSelected = {}, + onSettleBills = {}, + onShare = { _, _ -> }, + onDismiss = {} + ) + } +} + +@Preview(showBackground = true, name = "Balanced") +@Composable +fun ProjectSettlementUIBalancedPreview() { + MaterialTheme { + ProjectSettlementUI( + transactions = emptyList(), + userList = listOf( + UserItem(SETTLE_OPTIMAL, "None (Optimal)"), + UserItem(1, "Alice"), + UserItem(2, "Bob") + ), + selectedMemberId = SETTLE_OPTIMAL, + memberIdToName = mapOf(1L to "Alice", 2L to "Bob"), + onMemberSelected = {}, + onSettleBills = {}, + onShare = { _, _ -> }, + onDismiss = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt new file mode 100644 index 0000000..854f23f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt @@ -0,0 +1,71 @@ +package net.helcel.cowspent.android.settings + +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.app.NavUtils +import androidx.preference.PreferenceManager +import net.helcel.cowspent.android.about.AboutActivity +import net.helcel.cowspent.android.account.AccountActivity +import net.helcel.cowspent.theme.ThemeUtils +import net.helcel.cowspent.util.ColorUtils + +/** + * Allows to change application settings. + */ +class PreferencesActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + NavUtils.navigateUpFromSameTask(this@PreferencesActivity) + } + }) + + setResult(RESULT_CANCELED) + + setContent { + val context = LocalContext.current + val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val nightModeKey = stringResource(net.helcel.cowspent.R.string.pref_key_night_mode) + + var appColor by remember { + mutableIntStateOf(ColorUtils.primaryColor(context)) + } + var nightMode by remember { + mutableStateOf(sharedPreferences.getString(nightModeKey, "-1") ?: "-1") + } + + val isDarkTheme = when (nightMode) { + "1" -> false + "2" -> true + else -> androidx.compose.foundation.isSystemInDarkTheme() + } + + ThemeUtils.CowspentTheme(accentColor = appColor, darkTheme = isDarkTheme) { + SettingsScreen( + onBack = { NavUtils.navigateUpFromSameTask(this) }, + onAccountSettingsClick = { + startActivity(Intent(this, AccountActivity::class.java)) + }, + onAboutClick = { + startActivity(Intent(this, AboutActivity::class.java)) + }, + onColorSelected = { appColor = it }, + onNightModeChanged = { nightMode = it } + ) + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt new file mode 100644 index 0000000..b84290b --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt @@ -0,0 +1,449 @@ +package net.helcel.cowspent.android.settings + +import android.graphics.Color +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Brightness2 +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Sync +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +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 androidx.core.content.edit +import androidx.preference.PreferenceManager +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.ColorPicker +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.ColorUtils +import net.helcel.cowspent.util.Cowspent + +@Composable +fun SettingsScreen( + onBack: () -> Unit, + onAccountSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + onColorSelected: (Int) -> Unit, + onNightModeChanged: (String) -> Unit = {} +) { + val context = LocalContext.current + val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } + + val keyNightMode = stringResource(R.string.pref_key_night_mode) + val valNightModeSystem = stringResource(R.string.pref_value_night_mode_system) + val valNightModeNo = stringResource(R.string.pref_value_night_mode_no) + val valNightModeYes = stringResource(R.string.pref_value_night_mode_yes) + + val keyColorMode = stringResource(R.string.pref_key_color_mode) + val keyUseServerColor = stringResource(R.string.pref_key_use_server_color) + val keyUseSystemColor = stringResource(R.string.pref_key_use_system_color) + val keyColor = stringResource(R.string.pref_key_color) + val keyOfflineMode = stringResource(R.string.pref_key_offline_mode) + val keyShowArchived = stringResource(R.string.pref_key_show_archived) + + // States for preferences + var nightMode by remember(keyNightMode) { + mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1") + } + + var colorMode by remember(keyColorMode, keyUseServerColor, keyUseSystemColor) { + mutableStateOf(sharedPreferences.getString(keyColorMode, null) ?: run { + val useServer = sharedPreferences.getBoolean(keyUseServerColor, true) + val useSystem = sharedPreferences.getBoolean(keyUseSystemColor, true) + when { + useServer -> "server" + useSystem -> "system" + else -> "manual" + } + }) + } + + // Apply theme globally only when leaving settings to avoid flickering/restarts during selection + DisposableEffect(nightMode) { + onDispose { + Cowspent.setAppTheme(nightMode.toInt()) + } + } + + var appColor by remember(keyColor) { + mutableIntStateOf(sharedPreferences.getInt(keyColor, Color.BLUE)) + } + var offlineMode by remember(keyOfflineMode) { + mutableStateOf(sharedPreferences.getBoolean(keyOfflineMode, false)) + } + var showArchived by remember(keyShowArchived) { + mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false)) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_settings)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // Appearance + SettingsCategory(stringResource(R.string.settings_appearance_category)) + + SettingsSwitchPreference( + title = stringResource(R.string.setting_show_archived), + icon = Icons.Default.Archive, + checked = showArchived, + onCheckedChange = { + showArchived = it + sharedPreferences.edit { + putBoolean(keyShowArchived, it) + if (!it) { + val selectedProjectId = sharedPreferences.getLong("selected_project", 0) + if (selectedProjectId != 0L) { + val db = CowspentSQLiteOpenHelper.getInstance(context) + val project = db.getProject(selectedProjectId) + if (project?.isArchived == true) { + putLong("selected_project", 0) + } + } + } + } + } + ) + + SettingsListPreference( + title = stringResource(R.string.settings_night_mode_title), + icon = Icons.Default.Brightness2, + value = nightMode, + entries = mapOf( + valNightModeSystem to stringResource(R.string.pref_value_theme_system), + valNightModeNo to stringResource(R.string.pref_value_theme_light), + valNightModeYes to stringResource(R.string.pref_value_theme_dark) + ), + onValueChange = { + nightMode = it + sharedPreferences.edit { + putString(keyNightMode, it) + } + onNightModeChanged(it) + } + ) + + SettingsListPreference( + title = stringResource(R.string.settings_color_mode_title), + icon = Icons.Default.Palette, + value = colorMode, + entries = mapOf( + "system" to stringResource(R.string.pref_value_color_system), + "server" to stringResource(R.string.pref_value_color_server), + "manual" to stringResource(R.string.pref_value_color_manual) + ), + onValueChange = { mode -> + colorMode = mode + sharedPreferences.edit { + putString(keyColorMode, mode) + // Keep legacy flags in sync just in case + putBoolean(keyUseServerColor, mode == "server") + putBoolean(keyUseSystemColor, mode == "system") + } + if (mode == "server") { + CowspentSQLiteOpenHelper.getInstance(context).cowspentServerSyncHelper.runAccountProjectsSync() + } + onColorSelected(ColorUtils.primaryColor(context)) + } + ) + + if (colorMode == "manual") { + SettingsColorPreference( + title = stringResource(R.string.settings_color_title), + summary = stringResource(R.string.settings_color_summary), + icon = Icons.Default.Palette, + initialColor = appColor, + onColorSelected = { + appColor = it + sharedPreferences.edit { + putInt(keyColor, it) + } + onColorSelected(it) + } + ) + } + + // Network + SettingsCategory(stringResource(R.string.settings_network_category)) + + SettingsSwitchPreference( + title = stringResource(R.string.settings_offline_mode_title), + summary = stringResource(R.string.settings_offline_mode_summary), + icon = Icons.Default.Sync, + checked = offlineMode, + onCheckedChange = { + offlineMode = it + sharedPreferences.edit { + putBoolean(keyOfflineMode, it) + } + } + ) + + SettingsPreference( + title = stringResource(R.string.settings_server_settings), + icon = Icons.Default.AccountCircle, + onClick = onAccountSettingsClick + ) + + // Other + SettingsCategory(stringResource(R.string.settings_other_category)) + + SettingsPreference( + title = stringResource(R.string.settings_about), + icon = Icons.Default.Info, + onClick = onAboutClick + ) + } + } +} + +@Composable +fun SettingsCategory(title: String) { + Text( + text = title.uppercase(), + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), + color = MaterialTheme.colors.primary, + style = MaterialTheme.typography.overline, + fontWeight = FontWeight.Bold + ) +} + +@Composable +fun SettingsPreference( + title: String, + summary: String? = null, + icon: Any? = null, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsIcon(icon) + Spacer(modifier = Modifier.width(32.dp)) + Column { + Text(text = title, style = MaterialTheme.typography.subtitle1) + if (summary != null) { + Text(text = summary, style = MaterialTheme.typography.caption) + } + } + } +} + +@Composable +fun SettingsSwitchPreference( + title: String, + summary: String? = null, + icon: Any? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(16.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsIcon(icon) + Spacer(modifier = Modifier.width(32.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.subtitle1) + if (summary != null) { + Text(text = summary, style = MaterialTheme.typography.caption) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.colors.onSurface + ) + ) + } +} + +@Composable +fun SettingsListPreference( + title: String, + icon: Any? = null, + value: String, + entries: Map, + onValueChange: (String) -> Unit +) { + var showDialog by remember { mutableStateOf(false) } + + SettingsPreference( + title = title, + summary = entries[value], + icon = icon, + onClick = { showDialog = true } + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(title) }, + text = { + Column { + entries.forEach { (key, label) -> + Row( + Modifier.fillMaxWidth() + .selectable( + selected = (key == value), + onClick = { + onValueChange(key) + showDialog = false + } + ), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (key == value), + onClick = { + onValueChange(key) + showDialog = false + } + ) + Spacer(Modifier.width(8.dp)) + Text(text = label) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.simple_cancel)) + } + } + ) + } +} + +@Composable +fun SettingsColorPreference( + title: String, + summary: String? = null, + icon: Any? = null, + initialColor: Int, + onColorSelected: (Int) -> Unit +) { + var showDialog by remember { mutableStateOf(false) } + var tempColor by remember { mutableIntStateOf(initialColor) } + + SettingsPreference( + title = title, + summary = summary, + icon = icon, + onClick = { + tempColor = initialColor + showDialog = true + } + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(title) }, + text = { + ColorPicker(initialColor = tempColor) { + tempColor = it + } + }, + confirmButton = { + TextButton(onClick = { + onColorSelected(tempColor) + showDialog = false + }) { + Text(stringResource(R.string.simple_ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.simple_cancel)) + } + } + ) + } +} + +@Composable +fun SettingsIcon(icon: Any?) { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + when (icon) { + is ImageVector -> Icon(icon, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) + is Painter -> Icon(icon, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun SettingsScreenPreview() { + MaterialTheme { + SettingsScreen( + onBack = {}, + onAccountSettingsClick = {}, + onAboutClick = {}, + onColorSelected = {}, + onNightModeChanged = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt new file mode 100644 index 0000000..19410a1 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt @@ -0,0 +1,469 @@ +package net.helcel.cowspent.android.statistics + +import android.annotation.SuppressLint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Group +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.* +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBMember +import androidx.core.graphics.toColorInt + +@OptIn(ExperimentalMaterialApi::class) +@SuppressLint("UseKtx") +@Composable +fun ProjectSankeyDiagram( + projectName: String, + allMembers: List, + allBills: List, + customCategories: List, + onShareReady: (String) -> Unit +) { + val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName) + + var selectedMemberId by remember { mutableLongStateOf(-1L) } + var expanded by remember { mutableStateOf(false) } + + val activeBills = remember(allBills) { + allBills.filter { + it.state != DBBill.STATE_DELETED && + it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT + } + } + + val membersMap = remember(allMembers) { allMembers.associateBy { it.id } } + val categoriesMap = remember(customCategories) { customCategories.associateBy { it.remoteId.toInt() } } + + if (activeBills.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No data to display", style = MaterialTheme.typography.h6) + } + return + } + + // Consolidated spending calculation + val spendings = remember(activeBills, selectedMemberId, membersMap) { + val spentMap = mutableMapOf() + val catMap = mutableMapOf() + membersMap.keys.forEach { spentMap[it] = 0.0 } + + activeBills.forEach { bill -> + val totalWeight = bill.billOwers.sumOf { bo -> + membersMap[bo.memberId]?.weight ?: 1.0 + } + if (totalWeight > 0) { + if (selectedMemberId == -1L) { + catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount + bill.billOwers.forEach { bo -> + val weight = membersMap[bo.memberId]?.weight ?: 1.0 + val share = (bill.amount / totalWeight) * weight + spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share + } + } else { + bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo -> + val weight = membersMap[bo.memberId]?.weight ?: 1.0 + val share = (bill.amount / totalWeight) * weight + catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share + spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share + } + } + } + } + + val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } + val categoryList = catMap.toList().sortedByDescending { it.second } + memberList to categoryList + } + + val displayMemberSpendings = spendings.first + val displayCategorySpendings = spendings.second + val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } } + + LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) { + val statsText = StringBuilder() + statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n") + + val middleNode = if (selectedMemberId == -1L) "Total" else "Spent" + + displayMemberSpendings.forEach { (memberId, amount) -> + val name = membersMap[memberId]?.name ?: "???" + statsText.append("$name [${formatShortValue(amount)}] $middleNode\n") + } + + statsText.append("\n") + + displayCategorySpendings.forEach { (catRemoteId, amount) -> + val name = categoriesMap[catRemoteId]?.name ?: "Other" + statsText.append("$middleNode [${formatShortValue(amount)}] $name\n") + } + + statsText.append("\n") + + displayMemberSpendings.forEach { (memberId, _) -> + val member = membersMap[memberId] + if (member != null) { + val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128) + statsText.append(":${member.name} $hexColor\n") + } + } + + displayCategorySpendings.forEach { (catRemoteId, _) -> + val category = categoriesMap[catRemoteId] + if (category != null) { + statsText.append(":${category.name ?: "Other"} ${category.color}\n") + } + } + + onShareReady(statsText.toString()) + } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + val selectedMember = membersMap[selectedMemberId] + EditableExposedDropdownMenu( + value = selectedMember?.name ?: "All Members", + placeholder = "Filter by member", + expanded = expanded, + onExpandedChange = { expanded = it }, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedMember != null) { + UserAvatar( + name = selectedMember.name, + r = selectedMember.r, + g = selectedMember.g, + b = selectedMember.b, + disabled = !selectedMember.isActivated, + size = 24.dp + ) + } else { + Icon(Icons.Default.Group, contentDescription = null) + } + } + }, + content = { + DropdownMenuItem(onClick = { + selectedMemberId = -1L + expanded = false + }) { + Icon(Icons.Default.Group, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Text("All Members") + } + allMembers.forEach { member -> + DropdownMenuItem(onClick = { + selectedMemberId = member.id + expanded = false + }) { + UserAvatar( + name = member.name, + r = member.r, + g = member.g, + b = member.b, + disabled = !member.isActivated, + size = 24.dp + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(member.name) + } + } + } + ) + + if (totalAmount <= 0) { + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("No spending data for selected filter", style = MaterialTheme.typography.body1) + } + } else { + BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { + val nodeHeightMember = 76.dp + val nodeHeightCategory = 76.dp + val nodeHeightTotal = 42.dp + val horizontalGap = 8.dp + + val memberCount = displayMemberSpendings.size + val categoryCount = displayCategorySpendings.size + + val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0) + val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f) + val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f + + Canvas(modifier = Modifier.fillMaxSize()) { + val nodeHeightMemberPx = nodeHeightMember.toPx() + val nodeHeightCategoryPx = nodeHeightCategory.toPx() + val nodeHeightTotalPx = nodeHeightTotal.toPx() + val gapPx = horizontalGap.toPx() + + val totalWidthPx = size.width + val usableWidthPx = totalWidthPx - gapPx * maxGaps + val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0 + + val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat() + val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2 + + val topY = 0f + val middleY = (size.height - nodeHeightTotalPx) / 2 + val bottomY = size.height - nodeHeightCategoryPx + + val totalNodeColor = Color(0xFF333333) + + // Flows + var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 + var currentXInTotalTop = totalNodeXPx + displayMemberSpendings.forEach { (memberId, amount) -> + val member = membersMap[memberId] + val boxWidth = (amount * moneyScalePx).toFloat() + val color = member?.let { + Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) + } ?: Color.Gray + + if (boxWidth > 0.5f) { + drawSankeyFlow( + startX = currentXTop, + startY = topY + nodeHeightMemberPx * 0.5f, + startWidth = boxWidth, + endX = currentXInTotalTop, + endY = middleY + nodeHeightTotalPx * 0.5f, + endWidth = boxWidth, + startColor = color.copy(alpha = 0.5f), + endColor = totalNodeColor.copy(alpha = 0.35f) + ) + } + currentXTop += boxWidth + gapPx + currentXInTotalTop += boxWidth + } + + var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 + var currentXInTotalBottom = totalNodeXPx + displayCategorySpendings.forEach { (catRemoteId, amount) -> + val category = categoriesMap[catRemoteId] + val boxWidth = (amount * moneyScalePx).toFloat() + val color = category?.color?.let { + try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } + } ?: Color(0xFF999999) + + if (boxWidth > 0.5f) { + drawSankeyFlow( + startX = currentXInTotalBottom, + startY = middleY + nodeHeightTotalPx * 0.5f, + startWidth = boxWidth, + endX = currentXBottom, + endY = bottomY + nodeHeightCategoryPx * 0.5f, + endWidth = boxWidth, + startColor = totalNodeColor.copy(alpha = 0.35f), + endColor = color.copy(alpha = 0.5f) + ) + } + currentXInTotalBottom += boxWidth + currentXBottom += boxWidth + gapPx + } + + // Nodes + currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 + displayMemberSpendings.forEach { (memberId, amount) -> + val member = membersMap[memberId] + val width = (amount * moneyScalePx).toFloat() + val color = member?.let { + Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) + } ?: Color.Gray + + if (width > 0.5f) { + drawRoundRect( + color = color, + topLeft = Offset(currentXTop, topY), + size = Size(width, nodeHeightMemberPx), + cornerRadius = CornerRadius(10.dp.toPx()) + ) + } + currentXTop += width + gapPx + } + + drawRect( + color = totalNodeColor, + topLeft = Offset(totalNodeXPx, middleY), + size = Size(totalNodeWidthPx, nodeHeightTotalPx) + ) + + currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 + displayCategorySpendings.forEach { (catRemoteId, amount) -> + val category = categoriesMap[catRemoteId] + val width = (amount * moneyScalePx).toFloat() + val color = category?.color?.let { + try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } + } ?: Color(0xFF999999) + + if (width > 0.5f) { + drawRoundRect( + color = color, + topLeft = Offset(currentXBottom, bottomY), + size = Size(width, nodeHeightCategoryPx), + cornerRadius = CornerRadius(10.dp.toPx()) + ) + } + currentXBottom += width + gapPx + } + } + + // Member labels + var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2 + displayMemberSpendings.forEach { (memberId, amount) -> + val member = membersMap[memberId] + val widthValue = amount.toFloat() * moneyScale + val width = widthValue.dp + if (widthValue > 12f) { + Box( + modifier = Modifier + .offset(x = currentXTopLabel.dp, y = 0.dp) + .size(width, nodeHeightMember), + contentAlignment = Alignment.Center + ) { + if (width >= 36.dp) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = member?.name ?: "???", + fontSize = 16.sp, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold + ) + Text( + text = formatShortValue(amount), + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.9f) + ) + } + } + } + } + currentXTopLabel += widthValue + horizontalGap.value + } + + // Total label + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .height(nodeHeightTotal), + contentAlignment = Alignment.Center + ) { + Text( + text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}", + fontSize = 16.sp, + color = Color.White, + textAlign = TextAlign.Center, + fontWeight = FontWeight.ExtraBold + ) + } + + // Category labels + var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2 + displayCategorySpendings.forEach { (catRemoteId, amount) -> + val category = categoriesMap[catRemoteId] + val widthValue = amount.toFloat() * moneyScale + val width = widthValue.dp + if (widthValue > 12f) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .offset(x = currentXBottomLabel.dp) + .size(width, nodeHeightCategory), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = category?.icon ?: "❔", + fontSize = 20.sp + ) + if (width >= 32.dp) { + Text( + text = formatShortValue(amount), + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.9f) + ) + } + } + } + } + currentXBottomLabel += widthValue + horizontalGap.value + } + } + } + Spacer(Modifier.fillMaxWidth().height(32.dp)) + } +} + +private fun DrawScope.drawSankeyFlow( + startX: Float, + startY: Float, + startWidth: Float, + endX: Float, + endY: Float, + endWidth: Float, + startColor: Color, + endColor: Color +) { + val path = Path().apply { + moveTo(startX, startY) + cubicTo( + startX, startY + (endY - startY) * 0.5f, + endX, endY - (endY - startY) * 0.5f, + endX, endY + ) + lineTo(endX + endWidth, endY) + cubicTo( + endX + endWidth, endY - (endY - startY) * 0.5f, + startX + startWidth, startY + (endY - startY) * 0.5f, + startX + startWidth, startY + ) + close() + } + drawPath( + path = path, + brush = Brush.verticalGradient( + colors = listOf(startColor, endColor), + startY = startY, + endY = endY + ) + ) +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun ProjectSankeyDiagramPreview() { + MaterialTheme { + ProjectSankeyDiagram( + projectName = "Test Project", + allMembers = StatisticsMockData.members, + allBills = StatisticsMockData.bills, + customCategories = emptyList(), + onShareReady = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt new file mode 100644 index 0000000..1db969f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt @@ -0,0 +1,436 @@ +package net.helcel.cowspent.android.statistics + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ShowChart +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.formatShortValue +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.max + +enum class SpendingTimeView(val label: String) { + WEEKLY("Weekly"), + MONTHLY("Monthly"), + YEARLY("Yearly") +} + +@Composable +fun ProjectSpendingGraph( + projectName: String, + allMembers: List, + allBills: List, + onShareReady: (String) -> Unit +) { + val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName) + if (allBills.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No data to display", style = MaterialTheme.typography.h6) + } + return + } + + var timeView by remember { mutableStateOf(SpendingTimeView.MONTHLY) } + var showTotal by remember { mutableStateOf(false) } + var showMovingAverage by remember { mutableStateOf(true) } + + val sortedBills = remember(allBills) { allBills.sortedBy { it.timestamp } } + val projectMaxTimestamp = sortedBills.last().timestamp + + // Period generation logic + val periods = remember(timeView, sortedBills.first().timestamp, projectMaxTimestamp) { + val cal = Calendar.getInstance() + cal.timeInMillis = sortedBills.first().timestamp * 1000 + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + + when (timeView) { + SpendingTimeView.WEEKLY -> cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) + SpendingTimeView.MONTHLY -> cal.set(Calendar.DAY_OF_MONTH, 1) + SpendingTimeView.YEARLY -> cal.set(Calendar.DAY_OF_YEAR, 1) + } + + val list = mutableListOf() + val endLimit = projectMaxTimestamp * 1000 + while (cal.timeInMillis <= endLimit) { + list.add(cal.timeInMillis / 1000) + when (timeView) { + SpendingTimeView.WEEKLY -> cal.add(Calendar.WEEK_OF_YEAR, 1) + SpendingTimeView.MONTHLY -> cal.add(Calendar.MONTH, 1) + SpendingTimeView.YEARLY -> cal.add(Calendar.YEAR, 1) + } + } + list + } + + // Consolidated spending data calculation + val chartData = remember(allMembers, sortedBills, periods, showTotal) { + val memberSpending = mutableMapOf>() + allMembers.forEach { member -> + val spending = mutableListOf() + for (i in periods.indices) { + val start = periods[i] + val end = if (i + 1 < periods.size) periods[i + 1] else Long.MAX_VALUE + val amount = sortedBills.asSequence() + .filter { it.timestamp in start.. + val totalInPeriod = allMembers.sumOf { memberSpending[it.id]?.get(i) ?: 0.0 } + val currentMetric = if (showTotal) totalInPeriod else totalInPeriod / allMembers.size.coerceAtLeast(1) + lastEma = if (i == 0) currentMetric else alpha * currentMetric + (1 - alpha) * lastEma + lastEma + } + + val maxBar = if (showTotal) { + periods.indices.maxOfOrNull { i -> + allMembers.sumOf { memberSpending[it.id]?.get(i) ?: 0.0 } + } ?: 1.0 + } else { + periods.indices.maxOfOrNull { i -> + allMembers.maxOfOrNull { memberSpending[it.id]?.get(i) ?: 0.0 } ?: 0.0 + } ?: 1.0 + } + val maxVal = maxOf(maxBar, trend.maxOrNull() ?: 0.0).coerceAtLeast(1.0) + + Triple(memberSpending, trend, maxVal) + } + + val memberSpendingByPeriod = chartData.first + val trendLine = chartData.second + val maxSpendingInPeriod = chartData.third + + LaunchedEffect(memberSpendingByPeriod, periods, timeView, projectName) { + val statsText = StringBuilder() + statsText.append(shareStatsIntro).append("\n\n") + statsText.append("Spending Trend (${timeView.label}):\n") + periods.indices.forEach { i -> + val timestamp = periods[i] + val dateStr = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT).format(Date(timestamp * 1000)) + val total = allMembers.sumOf { memberSpendingByPeriod[it.id]?.get(i) ?: 0.0 } + if (total > 0) { + statsText.append("- $dateStr: ${total.toInt()}\n") + } + } + val grandTotal = allMembers.sumOf { m -> memberSpendingByPeriod[m.id]?.sum() ?: 0.0 } + statsText.append("\nTotal: ${grandTotal.toInt()}") + onShareReady(statsText.toString()) + } + + Column(modifier = Modifier.padding(16.dp).fillMaxSize()) { + Card( + elevation = 4.dp, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(12.dp)) { + val totalAvailableWidth = constraints.maxWidth.toFloat() + val totalHeight = constraints.maxHeight.toFloat() + + val yAxisWidth = 44.dp + val xAxisHeight = 32.dp + + val density = LocalDensity.current + val yAxisWidthPx = with(density) { yAxisWidth.toPx() } + val xAxisHeightPx = with(density) { xAxisHeight.toPx() } + + val chartHeight = totalHeight - xAxisHeightPx + + // Y-Axis Labels + Box(modifier = Modifier.width(yAxisWidth).height(with(density) { chartHeight.toDp() })) { + for (i in 0..5) { + val value = (i.toFloat() / 5) * maxSpendingInPeriod + val yOffset = chartHeight - (i.toFloat() / 5) * chartHeight + Text( + text = formatShortValue(value), + fontSize = 10.sp, + color = Color.Gray, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .offset(y = with(density) { (yOffset - 6.sp.toPx()).toDp() }) + .padding(end = 8.dp) + ) + } + } + + val scrollState = rememberScrollState() + LaunchedEffect(scrollState.maxValue) { + if (scrollState.maxValue > 0) { + scrollState.scrollTo(scrollState.maxValue) + } + } + val minPeriodWidth = when(timeView) { + SpendingTimeView.WEEKLY -> 48.dp + SpendingTimeView.MONTHLY -> 72.dp + SpendingTimeView.YEARLY -> 110.dp + } + val contentWidth = max(totalAvailableWidth - yAxisWidthPx, with(density) { periods.size * minPeriodWidth.toPx() }) + + Box( + modifier = Modifier + .offset(x = yAxisWidth) + .width(with(density) { (totalAvailableWidth - yAxisWidthPx).toDp() }) + .horizontalScroll(scrollState) + ) { + Column(modifier = Modifier.width(with(density) { contentWidth.toDp() })) { + // Chart area + val primaryColor = MaterialTheme.colors.primary + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(with(density) { chartHeight.toDp() }) + ) { + val gridColor = Color.Gray.copy(alpha = 0.15f) + for (i in 0..5) { + val y = size.height - (i.toFloat() / 5) * size.height + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1.dp.toPx() + ) + } + + val fullWidthPerPeriod = size.width / periods.size + val groupWidth = fullWidthPerPeriod * 0.75f + val groupSpacing = fullWidthPerPeriod * 0.25f + + periods.indices.forEach { i -> + if (showTotal) { + val totalAmount = allMembers.sumOf { memberSpendingByPeriod[it.id]?.get(i) ?: 0.0 } + if (totalAmount > 0) { + val barHeight = (totalAmount.toFloat() / maxSpendingInPeriod.toFloat()) * size.height + val x = i * fullWidthPerPeriod + groupSpacing / 2 + drawRoundRect( + color = primaryColor.copy(alpha = 0.8f), + topLeft = Offset(x, size.height - barHeight), + size = Size(groupWidth, barHeight), + cornerRadius = CornerRadius(4.dp.toPx(), 4.dp.toPx()) + ) + } + } else { + val activeMembers = allMembers.filter { (memberSpendingByPeriod[it.id]?.get(i) ?: 0.0) > 0 } + if (activeMembers.isNotEmpty()) { + val memberBarWidth = groupWidth / allMembers.size + val groupStartX = i * fullWidthPerPeriod + groupSpacing / 2 + val totalActiveWidth = activeMembers.size * memberBarWidth + val centeringOffset = (groupWidth - totalActiveWidth) / 2 + + activeMembers.forEachIndexed { activeIndex, member -> + val amount = memberSpendingByPeriod[member.id]!![i] + val barHeight = (amount.toFloat() / maxSpendingInPeriod.toFloat()) * size.height + val x = groupStartX + centeringOffset + activeIndex * memberBarWidth + + drawRoundRect( + color = Color(android.graphics.Color.rgb(member.r ?: 0, member.g ?: 0, member.b ?: 0)), + topLeft = Offset(x, size.height - barHeight), + size = Size(memberBarWidth * 0.85f, barHeight), + cornerRadius = CornerRadius(2.dp.toPx(), 2.dp.toPx()) + ) + } + } + } + } + + if (showMovingAverage && periods.isNotEmpty()) { + val linePath = Path() + trendLine.forEachIndexed { i, value -> + val x = i * fullWidthPerPeriod + fullWidthPerPeriod / 2f + val y = size.height - (value.toFloat() / maxSpendingInPeriod.toFloat()) * size.height + + if (i == 0) { + linePath.moveTo(x, y) + } else { + val prevX = (i - 1) * fullWidthPerPeriod + fullWidthPerPeriod / 2f + val prevY = size.height - (trendLine[i-1].toFloat() / maxSpendingInPeriod.toFloat()) * size.height + linePath.cubicTo( + prevX + (x - prevX) / 2f, prevY, + prevX + (x - prevX) / 2f, y, + x, y + ) + } + } + drawPath( + path = linePath, + color = primaryColor, + style = Stroke( + width = 2.5.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round + ) + ) + } + } + + Box(modifier = Modifier.fillMaxWidth().height(xAxisHeight)) { + val locale = LocalLocale.current.platformLocale + val labelIndices = when(timeView) { + SpendingTimeView.YEARLY -> periods.indices.toList() + SpendingTimeView.MONTHLY -> periods.indices.filter { it % 2 == 0 || it == periods.size - 1 } + SpendingTimeView.WEEKLY -> periods.indices.filter { it % 4 == 0 || it == periods.size - 1 } + } + + labelIndices.forEach { index -> + val timestamp = periods[index] + val cal = Calendar.getInstance().apply { timeInMillis = timestamp * 1000 } + val dateStr = when (timeView) { + SpendingTimeView.WEEKLY -> { + val week = cal.get(Calendar.WEEK_OF_YEAR) + val year = cal.get(Calendar.YEAR) % 100 + String.format(Locale.ROOT, "%02d/%02d", week, year) + } + SpendingTimeView.MONTHLY -> SimpleDateFormat("MM/yy", locale).format(Date(timestamp * 1000)) + SpendingTimeView.YEARLY -> "${cal.get(Calendar.YEAR)}" + } + val fullWidthPerPeriod = contentWidth / periods.size + val xPosition = index * fullWidthPerPeriod + (fullWidthPerPeriod / 2) + Text( + text = dateStr, + fontSize = 10.sp, + color = Color.Gray, + textAlign = TextAlign.Center, + modifier = Modifier + .offset(x = with(density) { (xPosition - 40.dp.toPx()).toDp() }) + .width(80.dp) + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .background(Color.Black.copy(alpha = 0.05f), MaterialTheme.shapes.medium) + .border(1.dp, Color.Black.copy(alpha = 0.1f), MaterialTheme.shapes.medium) + ) { + Row(modifier = Modifier.padding(4.dp)) { + SpendingTimeView.entries.forEach { view -> + val isSelected = timeView == view + Box( + modifier = Modifier + .background( + color = if (isSelected) MaterialTheme.colors.primary else Color.Transparent, + shape = MaterialTheme.shapes.medium + ) + .clickable { timeView = view } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = view.label, + fontSize = 12.sp, + color = if (isSelected) MaterialTheme.colors.onPrimary else Color.Gray, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { showMovingAverage = !showMovingAverage }) { + Icon( + if (showMovingAverage) Icons.Default.Timeline else Icons.AutoMirrored.Filled.ShowChart, + contentDescription = "Trend", + tint = if (showMovingAverage) MaterialTheme.colors.primary else Color.Gray + ) + } + Spacer(Modifier.width(4.dp)) + Text("Total", style = MaterialTheme.typography.caption, color = Color.Gray) + Switch( + checked = showTotal, + onCheckedChange = { showTotal = it }, + colors = SwitchDefaults.colors(checkedThumbColor = MaterialTheme.colors.primary) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (!showTotal) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 100.dp) + .verticalScroll(scrollState) + ) { + allMembers.chunked(3).forEach { rowMembers -> + Row(modifier = Modifier.fillMaxWidth()) { + rowMembers.forEach { member -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f).padding(bottom = 8.dp) + ) { + Box( + modifier = Modifier + .size(12.dp) + .background( + Color(android.graphics.Color.rgb(member.r ?: 0, member.g ?: 0, member.b ?: 0)), + shape = MaterialTheme.shapes.small + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(member.name, fontSize = 12.sp, maxLines = 1) + } + } + if (rowMembers.size == 1) Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} + + +@Preview(showBackground = true) +@Composable +fun ProjectSpendingGraphPreview() { + MaterialTheme { + ProjectSpendingGraph( + projectName = "Test Project", + allMembers = StatisticsMockData.members, + allBills = StatisticsMockData.bills, + onShareReady = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt new file mode 100644 index 0000000..8ff5c6a --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt @@ -0,0 +1,56 @@ +package net.helcel.cowspent.android.statistics + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils + +class ProjectStatisticsActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L) + if (projectId == -1L) { + finish() + return + } + + val db = CowspentSQLiteOpenHelper.getInstance(this) + + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } + if (proj == null) { + finish() + return@launch + } + + setContent { + ThemeUtils.CowspentTheme { + ProjectStatisticsScreen( + proj = proj, + db = db, + onBack = { finish() } + ) + } + } + } + } + + companion object { + private const val EXTRA_PROJECT_ID = "extra_project_id" + + fun createIntent(context: Context, projectId: Long): Intent { + return Intent(context, ProjectStatisticsActivity::class.java).apply { + putExtra(EXTRA_PROJECT_ID, projectId) + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsPreview.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsPreview.kt new file mode 100644 index 0000000..04a4257 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsPreview.kt @@ -0,0 +1,48 @@ +package net.helcel.cowspent.android.statistics + +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBBillOwer +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType + +object StatisticsMockData { + val project = DBProject( + id = 1L, + remoteId = "vacation", + password = "", + name = "Vacation 2024", + serverUrl = null, + email = null, + lastPayerId = 1L, + type = ProjectType.LOCAL, + lastSyncedTimestamp = null, + currencyName = "EUR", + isDeletionDisabled = false, + myAccessLevel = 4, + bearerToken = null, + archivedTs = null + ) + + val members = listOf( + DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null), + DBMember(2L, 0, 1L, "Bob", true, 1.0, 0, 100, 255, 100, null, null), + DBMember(3L, 0, 1L, "Charlie", true, 1.0, 0, 100, 100, 255, null, null) + ) + + val bills = listOf( + DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000 - 86400 * 10, "Hotel", 0, null, null, -13, "", -1).apply { + billOwers = listOf(DBBillOwer(1L, 1L, 1L), DBBillOwer(2L, 1L, 2L), DBBillOwer(3L, 1L, 3L)) + }, + DBBill(2L, 0, 1L, 2L, 50.0, System.currentTimeMillis() / 1000 - 86400 * 8, "Groceries", 0, null, null, -1, "", -2).apply { + billOwers = listOf(DBBillOwer(4L, 2L, 1L), DBBillOwer(5L, 2L, 2L), DBBillOwer(6L, 2L, 3L)) + }, + DBBill(3L, 0, 1L, 3L, 1.0, System.currentTimeMillis() / 1000 - 86400 * 5, "Gas", 0, null, null, -14, "", -1).apply { + billOwers = listOf(DBBillOwer(7L, 3L, 1L), DBBillOwer(8L, 3L, 2L), DBBillOwer(9L, 3L, 3L)) + }, + DBBill(4L, 0, 1L, 1L, 30.0, System.currentTimeMillis() / 1000 - 86400 * 2, "Dinner", 0, null, null, -12, "", -2).apply { + billOwers = listOf(DBBillOwer(10L, 4L, 1L), DBBillOwer(11L, 4L, 2L)) + } + ) +} + diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt new file mode 100644 index 0000000..7e19785 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt @@ -0,0 +1,155 @@ +package net.helcel.cowspent.android.statistics + +import android.content.Intent +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.Alignment +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.model.* +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.CategoryUtils + +@Composable +fun ProjectStatisticsScreen( + proj: DBProject, + db: CowspentSQLiteOpenHelper, + onBack: () -> Unit +) { + val context = LocalContext.current + val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) } + var selectedTab by rememberSaveable { + mutableIntStateOf(prefs.getInt("last_statistics_tab", 0)) + } + var currentShareText by remember { mutableStateOf("") } + val tabs = listOf( + stringResource(R.string.statistic_title), + "Trend", + "Sankey" + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.statistic_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { + if (currentShareText.isNotEmpty()) { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, currentShareText) + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, null)) + } + }) { + Icon(Icons.Default.Share, contentDescription = "Share") + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { + selectedTab = index + prefs.edit { putInt("last_statistics_tab", index) } + }, + text = { Text(title) } + ) + } + } + + val statsData by produceState(null, proj.id) { + value = withContext(Dispatchers.IO) { + val members = db.getMembersOfProject(proj.id, null) + val bills = db.getBillsOfProject(proj.id) + val categories = db.getCategories(proj.id) + val paymentModes = db.getPaymentModes(proj.id) + StatisticsData(members, bills, categories, paymentModes) + } + } + + if (statsData != null) { + val data = statsData!! + val defaultCategories = remember(proj.id) { CategoryUtils.getDefaultCategories(context, proj.id) } + val categories = remember(proj.type, data.categories, defaultCategories) { + val hardcoded = if (proj.type == ProjectType.LOCAL) { + defaultCategories + } else { + listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) + } + (data.categories + hardcoded).distinctBy { it.remoteId } + } + val categoryNoneLabel = stringResource(R.string.category_none) + val sankeyCategories = remember(proj.id, data.categories, defaultCategories, categoryNoneLabel) { + val noneCategory = DBCategory(0, 0, proj.id, categoryNoneLabel, "❌", "#9E9E9E") + (data.categories + defaultCategories + noneCategory).distinctBy { it.remoteId } + } + + when (selectedTab) { + 0 -> { + ProjectStatisticsTable( + proj = proj, + allMembers = data.members, + allBills = data.bills, + customCategories = categories, + customPaymentModes = data.paymentModes, + onShareReady = { currentShareText = it } + ) + } + 1 -> { + ProjectSpendingGraph( + projectName = proj.name.ifEmpty { proj.remoteId }, + allMembers = data.members, + allBills = data.bills, + onShareReady = { currentShareText = it } + ) + } + 2 -> { + ProjectSankeyDiagram( + projectName = proj.name.ifEmpty { proj.remoteId }, + allMembers = data.members, + allBills = data.bills, + customCategories = sankeyCategories, + onShareReady = { currentShareText = it } + ) + } + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } +} + +data class StatisticsData( + val members: List, + val bills: List, + val categories: List, + val paymentModes: List +) diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt new file mode 100644 index 0000000..b157c1e --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt @@ -0,0 +1,390 @@ +package net.helcel.cowspent.android.statistics + +import android.app.DatePickerDialog +import android.content.Context +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.* +import net.helcel.cowspent.model.* +import net.helcel.cowspent.util.CategoryUtils +import net.helcel.cowspent.util.SupportUtil +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.abs +import kotlin.math.round + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ProjectStatisticsTable( + proj: DBProject, + allMembers: List, + allBills: List, + customCategories: List, + customPaymentModes: List, + onShareReady: (String) -> Unit +) { + val context = LocalContext.current + val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) } + val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } + + var categoryId by remember { mutableIntStateOf(-1000) } + var paymentModeId by remember { mutableIntStateOf(-1000) } + var dateMin by remember { mutableStateOf(null) } + var dateMax by remember { mutableStateOf(null) } + + val categoryAll = stringResource(R.string.category_all) + val categoryNone = stringResource(R.string.category_none) + val categoryReimbursement = stringResource(R.string.category_reimbursement) + val categoryAllExceptReimbursement = stringResource(R.string.category_all_except_reimbursement) + + val paymentModeAll = stringResource(R.string.payment_mode_all) + val paymentModeNone = stringResource(R.string.payment_mode_none) + + val shareStatsHeader = stringResource(R.string.share_stats_header) + val shareStatsIntro = stringResource(R.string.share_stats_intro, proj.name.ifEmpty { proj.remoteId }) + + val categories = remember(proj.id, customCategories, categoryAll, categoryNone, categoryReimbursement, categoryAllExceptReimbursement) { + val list = mutableListOf>() + list.add(Triple(-1000, "📋", categoryAll)) + list.add(Triple(-100, "🧾", categoryAllExceptReimbursement)) + list.add(Triple(0, "❌", categoryNone)) + + val catsToUse = if (proj.type == ProjectType.LOCAL) { + CategoryUtils.getDefaultCategories(context, proj.id) + } else { + customCategories.ifEmpty { + CategoryUtils.getDefaultCategories(context, proj.id) + } + } + + catsToUse.forEach { + list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: "")) + } + list.distinctBy { it.first } + } + + val paymentModes = remember(proj.id, customPaymentModes, paymentModeAll, paymentModeNone) { + val list = mutableListOf>() + list.add(Triple(-1000, "💳", paymentModeAll)) + list.add(Triple(0, "❌", paymentModeNone)) + + val pmsToUse = if (proj.type == ProjectType.LOCAL) { + CategoryUtils.getDefaultPaymentModes(context, proj.id) + } else { + customPaymentModes.ifEmpty { + CategoryUtils.getDefaultPaymentModes(context, proj.id) + } + } + + pmsToUse.forEach { + list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: "")) + } + list.distinctBy { it.first } + } + + val stats = remember(allMembers, allBills, categoryId, paymentModeId, dateMin, dateMax, shareStatsHeader, shareStatsIntro) { + val membersNbBills = mutableMapOf() + val membersBalance = HashMap() + val membersPaid = HashMap() + val membersSpent = HashMap() + + SupportUtil.getStats( + allMembers, allBills, + membersNbBills, membersBalance, membersPaid, membersSpent, + categoryId, paymentModeId, dateMin, dateMax + ) + + var statsText = shareStatsIntro + "\n\n" + statsText += shareStatsHeader + "\n" + + var totalPaid = 0.0 + val memberStats = allMembers.map { m -> + val mPaid = membersPaid[m.id] ?: 0.0 + totalPaid += mPaid + val mSpent = membersSpent[m.id] ?: 0.0 + val mBalance = membersBalance[m.id] ?: 0.0 + + val rpaid = round(mPaid * 100.0) / 100.0 + val rspent = round(mSpent * 100.0) / 100.0 + val rbalance = round(abs(mBalance) * 100.0) / 100.0 + val sign = if (mBalance > 0.01) "+" else if (mBalance < -0.01) "-" else "" + + statsText += "\n${m.name} (" + statsText += (if (rpaid == 0.0) "--" else SupportUtil.normalNumberFormat.format(rpaid)) + " | " + statsText += (if (rspent == 0.0) "--" else SupportUtil.normalNumberFormat.format(rspent)) + " | " + statsText += "$sign${SupportUtil.normalNumberFormat.format(rbalance)})" + + MemberStat(m.name, mPaid, mSpent, mBalance) + } + + StatsResult(memberStats, totalPaid, statsText) + } + + val dateMinLong = remember(dateMin) { + dateMin?.let { try { sdf.parse(it)?.time } catch (_: Exception) { null } } + } + val dateMaxLong = remember(dateMax) { + dateMax?.let { try { sdf.parse(it)?.time } catch (_: Exception) { null } } + } + + LaunchedEffect(stats.statsText) { + onShareReady(stats.statsText) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Card( + elevation = 2.dp, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + var categoryExpanded by remember { mutableStateOf(value = false) } + val selectedCategory = categories.find { it.first == categoryId } + + EditableExposedDropdownMenu( + value = selectedCategory?.third ?: "", + placeholder = stringResource(R.string.setting_category), + expanded = categoryExpanded, + onExpandedChange = { categoryExpanded = it }, + onDismissRequest = { categoryExpanded = false }, + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedCategory != null) { + Text(text = selectedCategory.second, fontSize = 20.sp) + } else { + Icon(Icons.Default.Category, contentDescription = null) + } + } + }, + content = { + categories.forEach { category -> + DropdownMenuItem(onClick = { + categoryId = category.first + categoryExpanded = false + }) { + Text(text = category.second, fontSize = 20.sp) + Spacer(modifier = Modifier.width(12.dp)) + Text(category.third) + } + } + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + var pmExpanded by remember { mutableStateOf(false) } + val selectedPm = paymentModes.find { it.first == paymentModeId } + + EditableExposedDropdownMenu( + value = selectedPm?.third ?: "", + placeholder = stringResource(R.string.setting_payment_mode), + expanded = pmExpanded, + onExpandedChange = { pmExpanded = it }, + onDismissRequest = { pmExpanded = false }, + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedPm != null) { + Text(text = selectedPm.second, fontSize = 20.sp) + } else { + Icon(Icons.Default.Payment, contentDescription = null) + } + } + }, + content = { + paymentModes.forEach { pm -> + DropdownMenuItem(onClick = { + paymentModeId = pm.first + pmExpanded = false + }) { + Text(text = pm.second, fontSize = 20.sp) + Spacer(modifier = Modifier.width(12.dp)) + Text(pm.third) + } + } + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + ClickableOutlinedTextField( + value = dateMin?.let { sdf.parse(it)?.let { d -> dateFormat.format(d) } } ?: "", + onClick = { + showDatePicker(context, dateMin, sdf, maxDate = dateMaxLong) { dateMin = it } + }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.stats_date_min)) }, + leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) }, + trailingIcon = if (dateMin != null) { + { + IconButton(onClick = { dateMin = null }) { + Icon(Icons.Default.Close, contentDescription = null, tint = Color.Red.copy(alpha = 0.6f)) + } + } + } else null + ) + + Spacer(modifier = Modifier.width(8.dp)) + + ClickableOutlinedTextField( + value = dateMax?.let { sdf.parse(it)?.let { d -> dateFormat.format(d) } } ?: "", + onClick = { + showDatePicker(context, dateMax, sdf, minDate = dateMinLong) { dateMax = it } + }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.stats_date_max)) }, + leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) }, + trailingIcon = if (dateMax != null) { + { + IconButton(onClick = { dateMax = null }) { + Icon(Icons.Default.Close, contentDescription = null, tint = Color.Red.copy(alpha = 0.6f)) + } + } + } else null + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Table Header + Surface( + color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { + Text(stringResource(R.string.stats_who), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) + Text(stringResource(R.string.stats_paid), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) + Text(stringResource(R.string.stats_spent), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) + Text(stringResource(R.string.stats_balance), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + LazyColumn(modifier = Modifier.weight(1f)) { + items(stats.memberStats) { m -> + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(m.name, modifier = Modifier.weight(2f), color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Medium) + + Text( + if (m.paid == 0.0) "--" else SupportUtil.normalNumberFormat.format(m.paid), + modifier = Modifier.weight(1.5f), + textAlign = TextAlign.End, + color = MaterialTheme.colors.onSurface + ) + + Text( + if (m.spent == 0.0) "--" else SupportUtil.normalNumberFormat.format(m.spent), + modifier = Modifier.weight(1.5f), + textAlign = TextAlign.End, + color = MaterialTheme.colors.onSurface + ) + + val balanceColor = if (m.balance > 0.01) colorResource(R.color.green) else if (m.balance < -0.01) colorResource(R.color.red) else MaterialTheme.colors.onSurface + val sign = if (m.balance > 0.01) "+" else if (m.balance < -0.01) "-" else "" + Text( + "$sign${SupportUtil.normalNumberFormat.format(abs(m.balance))}", + modifier = Modifier.weight(1.5f), + textAlign = TextAlign.End, + color = balanceColor, + fontWeight = FontWeight.Bold + ) + } + Divider(thickness = 0.5.dp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)) + } + } + + + Card( + elevation = 4.dp, + shape = MaterialTheme.shapes.medium, + backgroundColor = MaterialTheme.colors.primary, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.padding(16.dp, 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.total, SupportUtil.normalNumberFormat.format(stats.totalPaid)), + style = MaterialTheme.typography.h6, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onPrimary + ) + } + } + } +} + +private fun showDatePicker( + context: Context, + currentDate: String?, + sdf: SimpleDateFormat, + minDate: Long? = null, + maxDate: Long? = null, + onDateSelected: (String) -> Unit +) { + val calendar = Calendar.getInstance() + currentDate?.let { + try { sdf.parse(it)?.let { date -> calendar.time = date } } catch (_: Exception) {} + } + val dialog = DatePickerDialog( + context, + { _, year, month, day -> + val cal = Calendar.getInstance().apply { + set(Calendar.YEAR, year) + set(Calendar.MONTH, month) + set(Calendar.DAY_OF_MONTH, day) + } + onDateSelected(sdf.format(cal.time)) + }, + calendar[Calendar.YEAR], + calendar[Calendar.MONTH], + calendar[Calendar.DAY_OF_MONTH] + ) + minDate?.let { dialog.datePicker.minDate = it } + maxDate?.let { dialog.datePicker.maxDate = it } + dialog.show() +} + +data class MemberStat(val name: String, val paid: Double, val spent: Double, val balance: Double) +data class StatsResult(val memberStats: List, val totalPaid: Double, val statsText: String) + + +@Preview(showBackground = true) +@Composable +fun ProjectStatisticsTablePreview() { + MaterialTheme { + ProjectStatisticsTable( + proj = StatisticsMockData.project, + allMembers = StatisticsMockData.members, + allBills = StatisticsMockData.bills, + customCategories = emptyList(), + customPaymentModes = emptyList(), + onShareReady = {} + ) } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/Category.kt b/app/src/main/java/net/helcel/cowspent/model/Category.kt new file mode 100644 index 0000000..7ff44e0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/Category.kt @@ -0,0 +1,5 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class Category(val memberName: String?, val memberId: Long?) : Serializable diff --git a/app/src/main/java/net/helcel/cowspent/model/CreditDebt.kt b/app/src/main/java/net/helcel/cowspent/model/CreditDebt.kt new file mode 100644 index 0000000..65c6790 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/CreditDebt.kt @@ -0,0 +1,3 @@ +package net.helcel.cowspent.model + +class CreditDebt(var memberId: Long, var balance: Double) diff --git a/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt new file mode 100644 index 0000000..b53ccd0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt @@ -0,0 +1,17 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBAccountProject( + var id: Long, + var remoteId: String, + var password: String?, + var name: String, + var ncUrl: String, + var archivedTs: Long? = null +) : Serializable { + + override fun toString(): String { + return "#DBAccountProject$id/$remoteId,$name, $ncUrl, $password, archivedTs=$archivedTs" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBBill.kt b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt new file mode 100644 index 0000000..2ca7d20 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt @@ -0,0 +1,121 @@ +package net.helcel.cowspent.model + +import android.util.Log +import java.io.Serializable +import java.util.Calendar +import java.util.Locale + +open class DBBill( + var id: Long, + var remoteId: Long, + var projectId: Long, + var payerId: Long, + var amount: Double, + var timestamp: Long, + var what: String, + var state: Int, + var repeat: String?, + var paymentMode: String?, + var categoryRemoteId: Int, + var comment: String?, + var paymentModeRemoteId: Int +) : Item, Serializable { + + var formattedWhat: String = "" + var formattedSubtitle: String = "" + + var billOwers: List = ArrayList() + + val billOwersIds: List + get() { + val result: MutableList = ArrayList() + for (bo in billOwers) { + result.add(bo.memberId) + } + return result + } + + val date: String + get() { + val cal = Calendar.getInstance() + cal.timeInMillis = timestamp * 1000 + Log.v("ll", "[$what] get date ts $timestamp year ${cal[Calendar.YEAR]}") + val month = cal[Calendar.MONTH] + 1 + val day = cal[Calendar.DAY_OF_MONTH] + return "${cal[Calendar.YEAR]}-${String.format(Locale.ROOT, "%02d", month)}-${ + String.format(Locale.ROOT, "%02d", day) + }" + } + + val time: String + get() { + val cal = Calendar.getInstance() + cal.timeInMillis = timestamp * 1000 + return "${String.format(Locale.ROOT, "%02d", cal[Calendar.HOUR_OF_DAY])}:${ + String.format(Locale.ROOT, "%02d", cal[Calendar.MINUTE]) + }" + } + + + override fun toString(): String { + return "#DBBill$id/$remoteId,$projectId, $payerId, $amount, $timestamp, $what, $state, $repeat, $paymentMode, $categoryRemoteId" + } + + override fun isSection(): Boolean { + return false + } + + companion object { + const val PAYMODE_NONE = "n" + const val PAYMODE_CARD = "c" + const val PAYMODE_CASH = "b" + const val PAYMODE_CHECK = "f" + const val PAYMODE_TRANSFER = "t" + const val PAYMODE_ONLINE_SERVICE = "o" + + const val PAYMODE_ID_NONE = 0 + const val PAYMODE_ID_CARD = -1 + const val PAYMODE_ID_CASH = -2 + const val PAYMODE_ID_CHECK = -3 + const val PAYMODE_ID_TRANSFER = -4 + const val PAYMODE_ID_ONLINE_SERVICE = -5 + + @JvmField + val oldPmIdToNew: Map = object : HashMap() { + init { + put(PAYMODE_NONE, PAYMODE_ID_NONE) + put(PAYMODE_CARD, PAYMODE_ID_CARD) + put(PAYMODE_CASH, PAYMODE_ID_CASH) + put(PAYMODE_CHECK, PAYMODE_ID_CHECK) + put(PAYMODE_TRANSFER, PAYMODE_ID_TRANSFER) + put(PAYMODE_ONLINE_SERVICE, PAYMODE_ID_ONLINE_SERVICE) + } + } + + const val CATEGORY_NONE = 0 + const val CATEGORY_GROCERIES = -1 + const val CATEGORY_LEISURE = -2 + const val CATEGORY_RENT = -3 + const val CATEGORY_BILLS = -4 + const val CATEGORY_CULTURE = -5 + const val CATEGORY_HEALTH = -6 + const val CATEGORY_SHOPPING = -10 + const val CATEGORY_REIMBURSEMENT = -11 + const val CATEGORY_RESTAURANT = -12 + const val CATEGORY_ACCOMMODATION = -13 + const val CATEGORY_TRANSPORT = -14 + const val CATEGORY_SPORT = -15 + + const val STATE_OK = 0 + const val STATE_ADDED = 1 + const val STATE_EDITED = 2 + const val STATE_DELETED = 3 + + const val NON_REPEATED = "n" + const val REPEAT_DAY = "d" + const val REPEAT_WEEK = "w" + const val REPEAT_FORTNIGHT = "b" + const val REPEAT_MONTH = "m" + const val REPEAT_YEAR = "y" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBBillOwer.kt b/app/src/main/java/net/helcel/cowspent/model/DBBillOwer.kt new file mode 100644 index 0000000..f281ef6 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBBillOwer.kt @@ -0,0 +1,14 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBBillOwer( + var id: Long, + var billId: Long, + var memberId: Long +) : Serializable { + + override fun toString(): String { + return "#DBBillOwer$id/$billId,$memberId" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt b/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt new file mode 100644 index 0000000..ff71c93 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt @@ -0,0 +1,17 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBCategory( + var id: Long, + var remoteId: Long, + var projectId: Long, + var name: String?, + var icon: String, + var color: String +) : Serializable { + + override fun toString(): String { + return "#DBCategory$id/$remoteId,$name" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBCurrency.kt b/app/src/main/java/net/helcel/cowspent/model/DBCurrency.kt new file mode 100644 index 0000000..9784dec --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBCurrency.kt @@ -0,0 +1,17 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBCurrency( + var id: Long, + var remoteId: Long, + var projectId: Long, + var name: String?, + var exchangeRate: Double, + var state: Int +) : Serializable { + + override fun toString(): String { + return "#DBCurrency$id/$remoteId,$name , state: $state" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBMember.kt b/app/src/main/java/net/helcel/cowspent/model/DBMember.kt new file mode 100644 index 0000000..9eef451 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBMember.kt @@ -0,0 +1,23 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBMember( + var id: Long, + var remoteId: Long, + var projectId: Long, + var name: String, + var isActivated: Boolean, + var weight: Double, + var state: Int, + var r: Int?, + var g: Int?, + var b: Int?, + var ncUserId: String?, + var avatar: String? +) : Serializable { + + override fun toString(): String { + return "#DBMember$id/$remoteId,$name, p$projectId, $weight, $isActivated" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt b/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt new file mode 100644 index 0000000..a35345d --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt @@ -0,0 +1,17 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBPaymentMode( + var id: Long, + var remoteId: Long, + var projectId: Long, + var name: String?, + var icon: String, + var color: String +) : Serializable { + + override fun toString(): String { + return "#DBPaymentMode$id/$remoteId,$name" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/DBProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt new file mode 100644 index 0000000..19d513c --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt @@ -0,0 +1,75 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class DBProject( + var id: Long, + var remoteId: String, + var password: String, + var name: String, + var serverUrl: String?, + var email: String?, + var lastPayerId: Long?, + var type: ProjectType, + var lastSyncedTimestamp: Long?, + var currencyName: String?, + var isDeletionDisabled: Boolean, + var myAccessLevel: Int, + var bearerToken: String?, + var archivedTs: Long? = null, + var latestBillTs: Long = 0L +) : Serializable { + + val isArchived: Boolean + get() = archivedTs != null && archivedTs!! > 0 + + val isLocal: Boolean + get() = ProjectType.LOCAL == type + + fun getRequestBaseUrl(isOcsRequest: Boolean): String { + val url = serverUrl ?: "" + return if (!isOcsRequest) { + url.replace("/+$".toRegex(), "") + } else { + url.replace("/+$".toRegex(), "") + .replace("/index.php/apps/cospend", "/ocs/v2.php/apps/cospend") + } + } + + override fun toString(): String { + return "#DBProject$id/$remoteId,$name, $serverUrl, $email" + } + + fun isShareable(): Boolean { + return !serverUrl.isNullOrEmpty() + } + + fun getShareUrl(): String { + val url = serverUrl ?: "" + val strippedUrl = url + .replace("https://", "") + .replace("http://", "") + .replace("/index.php/apps/cospend", "") + + val protocol = if (type == ProjectType.IHATEMONEY) "ihatemoney" else "cospend" + return "$protocol://$strippedUrl/$remoteId/$password" + } + + fun getPublicWebUrl(): String { + val url = serverUrl ?: "" + return if (url.contains("index.php/apps/cospend")) { + "$url/loginproject/$remoteId" + } else { + "$url/$remoteId" + } + } + + companion object { + const val ACCESS_LEVEL_UNKNOWN = -1 + const val ACCESS_LEVEL_NONE = 0 + const val ACCESS_LEVEL_VIEWER = 1 + const val ACCESS_LEVEL_PARTICIPANT = 2 + const val ACCESS_LEVEL_MAINTAINER = 3 + const val ACCESS_LEVEL_ADMIN = 4 + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt b/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt new file mode 100644 index 0000000..51947a4 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt @@ -0,0 +1,26 @@ +package net.helcel.cowspent.model + +import java.io.Serializable + +class GroupedBill( + val sourceBills: List +) : DBBill( + sourceBills.first().id, + sourceBills.first().remoteId, + sourceBills.first().projectId, + sourceBills.first().payerId, + sourceBills.sumOf { it.amount }, + sourceBills.first().timestamp, + sourceBills.first().what, + sourceBills.first().state, + sourceBills.first().repeat, + sourceBills.first().paymentMode, + sourceBills.first().categoryRemoteId, + sourceBills.first().comment, + sourceBills.first().paymentModeRemoteId +), Serializable { + init { + this.formattedWhat = sourceBills.first().formattedWhat + this.billOwers = sourceBills.flatMap { it.billOwers } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/Item.kt b/app/src/main/java/net/helcel/cowspent/model/Item.kt new file mode 100644 index 0000000..082fa3f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/Item.kt @@ -0,0 +1,5 @@ +package net.helcel.cowspent.model + +interface Item { + fun isSection(): Boolean +} diff --git a/app/src/main/java/net/helcel/cowspent/model/ProjectType.kt b/app/src/main/java/net/helcel/cowspent/model/ProjectType.kt new file mode 100644 index 0000000..241471a --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/ProjectType.kt @@ -0,0 +1,20 @@ +package net.helcel.cowspent.model + +enum class ProjectType(val id: String) { + LOCAL("l"), COSPEND("c"), IHATEMONEY("i"); + + companion object { + private val reverseMap: Map = HashMap() + + init { + for (type in entries) { + (reverseMap as MutableMap)[type.id] = type + } + } + + @JvmStatic + fun getTypeById(id: String?): ProjectType? { + return reverseMap[id] + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/model/SectionItem.kt b/app/src/main/java/net/helcel/cowspent/model/SectionItem.kt new file mode 100644 index 0000000..c70a0e9 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/SectionItem.kt @@ -0,0 +1,8 @@ +package net.helcel.cowspent.model + +class SectionItem(var title: String) : Item { + override fun isSection(): Boolean { + return true + } + +} diff --git a/app/src/main/java/net/helcel/cowspent/model/Transaction.kt b/app/src/main/java/net/helcel/cowspent/model/Transaction.kt new file mode 100644 index 0000000..5c5e646 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/Transaction.kt @@ -0,0 +1,3 @@ +package net.helcel.cowspent.model + +class Transaction(var owerMemberId: Long, var receiverMemberId: Long, var amount: Double) diff --git a/app/src/main/java/net/helcel/cowspent/model/UserItem.kt b/app/src/main/java/net/helcel/cowspent/model/UserItem.kt new file mode 100644 index 0000000..22b47a0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/UserItem.kt @@ -0,0 +1,3 @@ +package net.helcel.cowspent.model + +class UserItem(var id: Long, var name: String) \ No newline at end of file diff --git a/app/src/main/java/net/helcel/cowspent/model/parsed/AustrianBillQrCode.kt b/app/src/main/java/net/helcel/cowspent/model/parsed/AustrianBillQrCode.kt new file mode 100644 index 0000000..46d2da0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/parsed/AustrianBillQrCode.kt @@ -0,0 +1,5 @@ +package net.helcel.cowspent.model.parsed + +import java.util.Date + +class AustrianBillQrCode(val cashDeskId: String, val date: Date, val amount: Double) diff --git a/app/src/main/java/net/helcel/cowspent/model/parsed/CroatianBillQrCode.kt b/app/src/main/java/net/helcel/cowspent/model/parsed/CroatianBillQrCode.kt new file mode 100644 index 0000000..29d7e0f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/model/parsed/CroatianBillQrCode.kt @@ -0,0 +1,5 @@ +package net.helcel.cowspent.model.parsed + +import java.time.LocalDateTime + +data class CroatianBillQrCode(val date: LocalDateTime?, val amount: Double) diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt new file mode 100644 index 0000000..cc73377 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -0,0 +1,1248 @@ +package net.helcel.cowspent.persistence + +import android.annotation.SuppressLint +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.text.TextUtils +import android.util.Log +import androidx.annotation.WorkerThread +import androidx.preference.PreferenceManager +import net.helcel.cowspent.R +import net.helcel.cowspent.android.main.BillsListViewActivity +import net.helcel.cowspent.model.* +import net.helcel.cowspent.util.SupportUtil +import java.text.SimpleDateFormat +import java.util.* + +/** + * Helps to add, get, update and delete bills, members, projects with the option to trigger a sync with the server. + */ +class CowspentSQLiteOpenHelper private constructor(val context: Context) : + SQLiteOpenHelper(context, database_name, null, database_version) { + + val cowspentServerSyncHelper: CowspentServerSyncHelper = CowspentServerSyncHelper.getInstance(this) + + override fun onCreate(db: SQLiteDatabase) { + createTableMembers(db) + createTableBills(db) + createTableBillowers(db) + createTableProjects(db) + createTableAccountProjects(db) + createTableCategories(db) + createTablePaymentModes(db) + createTableCurrencies(db) + createIndexes(db) + } + + private fun createTableMembers(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_members ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId INTEGER, " + + "$key_projectid INTEGER, " + + "$key_name TEXT, " + + "$key_activated INTEGER, " + + "$key_weight FLOAT, " + + "$key_r INTEGER, " + + "$key_g INTEGER, " + + "$key_b INTEGER, " + + "$key_nc_userid TEXT, " + + "$key_avatar TEXT, " + + "$key_state INTEGER)" + ) + } + + private fun createTableProjects(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_projects ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId TEXT, " + + "$key_name TEXT, " + + "$key_ihmUrl TEXT, " + + "$key_password TEXT, " + + "$key_bearer_token TEXT, " + + "$key_currencyName TEXT, " + + "$key_deletionDisabled INTEGER, " + + "$key_myAccessLevel INTEGER DEFAULT ${DBProject.ACCESS_LEVEL_UNKNOWN}, " + + "$key_lastPayerId INTEGER, " + + "$key_lastSyncTimestamp INTEGER DEFAULT 0, " + + "$key_email TEXT, " + + "$key_type TEXT, " + + "$key_archived INTEGER DEFAULT 0)" + ) + } + + private fun createTableBills(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_bills ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId INTEGER, " + + "$key_projectid INTEGER, " + + "$key_payer_id INTEGER, " + + "$key_amount FLOAT, " + + "$key_what TEXT, " + + "$key_state INTEGER, " + + "$key_timestamp INTEGER, " + + "$key_payment_mode TEXT DEFAULT \"n\", " + + "$key_category_id INTEGER DEFAULT 0, " + + "$key_repeat TEXT, " + + "$key_comment TEXT DEFAULT \"\", " + + "$key_payment_mode_id INTEGER DEFAULT 0)" + ) + } + + private fun createTableBillowers(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_billowers ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_billId INTEGER, " + + "$key_member_id INTEGER)" + ) + } + + private fun createTableAccountProjects(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_account_projects ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId TEXT, " + + "$key_name TEXT, " + + "$key_ncUrl TEXT, " + + "$key_bearer_token TEXT, " + + "$key_password TEXT, " + + "$key_archived INTEGER DEFAULT 0)" + ) + } + + private fun createTableCategories(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_categories ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId INTEGER, " + + "$key_projectid INTEGER, " + + "$key_name TEXT, " + + "$key_icon TEXT, " + + "$key_color TEXT)" + ) + } + + private fun createTablePaymentModes(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_payment_modes ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId INTEGER, " + + "$key_projectid INTEGER, " + + "$key_name TEXT, " + + "$key_icon TEXT, " + + "$key_color TEXT)" + ) + } + + private fun createTableCurrencies(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE $table_currencies ( " + + "$key_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "$key_remoteId INTEGER, " + + "$key_projectid INTEGER, " + + "$key_name TEXT, " + + "$key_exchangeRate FLOAT, " + + "$key_state INTEGER)" + ) + } + + @SuppressLint("Range") + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_lastPayerId INTEGER DEFAULT 0") + } + if (oldVersion < 3) { + db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_repeat TEXT") + } + if (oldVersion < 4) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_type TEXT") + val projects = getProjectsCustom("", arrayOf(), default_order, db) + for (project in projects) { + val url = project.serverUrl + project.type = if (url == null) ProjectType.LOCAL else ProjectType.COSPEND + updateProject( + project.id, project.name, project.email, + project.password, project.lastPayerId, project.type, + project.lastSyncedTimestamp, project.currencyName, + project.isDeletionDisabled, project.myAccessLevel, project.bearerToken, + project.archivedTs, db + ) + } + } + if (oldVersion < 5) { + createTableAccountProjects(db) + createIndex(db, table_account_projects) + } + if (oldVersion < 6) { + db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_payment_mode TEXT DEFAULT \"n\"") + db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_category_id INTEGER DEFAULT 0") + } + if (oldVersion < 7) { + db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_r INTEGER DEFAULT NULL") + db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_g INTEGER DEFAULT NULL") + db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_b INTEGER DEFAULT NULL") + } + if (oldVersion < 8) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_lastSyncTimestamp INTEGER DEFAULT 0") + } + if (oldVersion < 9) { + createTableCategories(db) + createIndex(db, table_categories) + } + if (oldVersion < 10) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_currencyName TEXT") + createTableCurrencies(db) + createIndex(db, table_currencies) + } + if (oldVersion < 11) { + db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_timestamp INTEGER") + val idToTs: MutableMap = HashMap() + val sdfDate = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + val cursor = db.query(table_bills, arrayOf(key_id, "DATE"), "", arrayOf(), null, null, null) + val dateNow = Date() + while (cursor.moveToNext()) { + val id = cursor.getLong(cursor.getColumnIndex(key_id)) + val dateStr = cursor.getString(cursor.getColumnIndex("DATE")) + val date = try { + sdfDate.parse(dateStr) + } catch (_: Exception) { + dateNow + } + val timestamp = (date?.time ?: System.currentTimeMillis()) / 1000 + idToTs[id] = timestamp + } + cursor.close() + for (billId in idToTs.keys) { + val timestamp = idToTs[billId]!! + val values = ContentValues() + values.put(key_timestamp, timestamp) + db.update(table_bills, values, "$key_id = ?", arrayOf(billId.toString())) + } + } + if (oldVersion < 12) { + db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_nc_userid TEXT DEFAULT NULL") + db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_avatar TEXT DEFAULT NULL") + } + if (oldVersion < 13) { + db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_comment TEXT DEFAULT \"\"") + } + if (oldVersion < 14) { + val projects = getProjectsCustom("", arrayOf(), default_order, db) + for (project in projects) { + updateProject( + project.id, project.name, project.email, + project.password, project.lastPayerId, project.type, + 0L, project.currencyName, project.isDeletionDisabled, project.myAccessLevel, project.bearerToken, + project.archivedTs, db + ) + } + } + if (oldVersion < 15) { + db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_payment_mode_id INTEGER DEFAULT 0") + for (key in DBBill.oldPmIdToNew.keys) { + val values = ContentValues() + values.put(key_payment_mode_id, DBBill.oldPmIdToNew[key]) + db.update(table_bills, values, "$key_payment_mode = ?", arrayOf(key)) + } + createTablePaymentModes(db) + createIndex(db, table_payment_modes) + } + if (oldVersion < 16) { + db.execSQL("ALTER TABLE $table_currencies ADD COLUMN $key_state INTEGER DEFAULT ${DBBill.STATE_OK}") + } + if (oldVersion < 17) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_deletionDisabled INTEGER") + } + if (oldVersion < 18) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_myAccessLevel INTEGER DEFAULT ${DBProject.ACCESS_LEVEL_UNKNOWN}") + } + if (oldVersion < 19) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_bearer_token TEXT") + } + if (oldVersion < 20) { + db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_archived INTEGER DEFAULT 0") + db.execSQL("ALTER TABLE $table_account_projects ADD COLUMN $key_archived INTEGER DEFAULT 0") + } + } + + override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + recreateDatabase(db) + } + + private fun recreateDatabase(db: SQLiteDatabase) { + dropIndexes(db) + db.execSQL("DROP TABLE $table_members") + db.execSQL("DROP TABLE $table_projects") + db.execSQL("DROP TABLE $table_bills") + db.execSQL("DROP TABLE $table_billowers") + db.execSQL("DROP TABLE $table_account_projects") + db.execSQL("DROP TABLE $table_categories") + db.execSQL("DROP TABLE $table_payment_modes") + db.execSQL("DROP TABLE $table_currencies") + onCreate(db) + } + + private fun dropIndexes(db: SQLiteDatabase) { + val c = db.query("sqlite_master", arrayOf("name"), "type=?", arrayOf("index"), null, null, null) + while (c.moveToNext()) { + db.execSQL("DROP INDEX " + c.getString(0)) + } + c.close() + } + + private fun createIndexes(db: SQLiteDatabase) { + createIndex(db, table_members) + createIndex(db, table_projects) + createIndex(db, table_bills) + createIndex(db, table_billowers) + createIndex(db, table_account_projects) + createIndex(db, table_categories) + createIndex(db, table_payment_modes) + createIndex(db, table_currencies) + } + + private fun createIndex(db: SQLiteDatabase, table: String) { + val indexName = "${table}_${key_id}_idx" + db.execSQL("CREATE INDEX IF NOT EXISTS $indexName ON $table($key_id)") + } + + fun addAccountProject(accountProject: DBAccountProject): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, accountProject.remoteId) + values.put(key_password, accountProject.password) + values.put(key_ncUrl, accountProject.ncUrl) + values.put(key_name, accountProject.name) + values.put(key_archived, accountProject.archivedTs ?: 0L) + return db.insert(table_account_projects, null, values) + } + + val accountProjects: List + get() = getAccountProjectsCustom("", arrayOf(), default_order) + + @WorkerThread + private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getAccountProjectsCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy) + val accountProjects: MutableList = ArrayList() + while (cursor.moveToNext()) { + accountProjects.add(getAccountProjectFromCursor(cursor)) + } + cursor.close() + return accountProjects + } + + @SuppressLint("Range") + private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject { + val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) + return DBAccountProject( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getString(cursor.getColumnIndex(key_remoteId)), + cursor.getString(cursor.getColumnIndex(key_password)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_ncUrl)), + if (archivedTs > 0) archivedTs else null + ) + } + + fun clearAccountProjects() { + val db = writableDatabase + db.delete(table_account_projects, null, null) + } + + fun addPaymentMode(paymentMode: DBPaymentMode): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, paymentMode.remoteId) + values.put(key_projectid, paymentMode.projectId) + values.put(key_name, paymentMode.name) + values.put(key_icon, paymentMode.icon) + values.put(key_color, paymentMode.color) + return db.insert(table_payment_modes, null, values) + } + + fun getPaymentMode(remoteId: Long, projectId: Long): DBPaymentMode? { + val paymentModes = getPaymentModesCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projectId.toString()), + null + ) + return if (paymentModes.isEmpty()) null else paymentModes[0] + } + + fun getPaymentModes(projectId: Long): List { + return getPaymentModesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) + } + + @WorkerThread + private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getPaymentModesCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy) + val paymentModes: MutableList = ArrayList() + while (cursor.moveToNext()) { + paymentModes.add(getPaymentModeFromCursor(cursor)) + } + cursor.close() + return paymentModes + } + + @SuppressLint("Range") + private fun getPaymentModeFromCursor(cursor: Cursor): DBPaymentMode { + return DBPaymentMode( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_icon)), + cursor.getString(cursor.getColumnIndex(key_color)) + ) + } + + fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?) { + val db = writableDatabase + val values = ContentValues() + if (name != null) values.put(key_name, name) + if (icon != null) values.put(key_icon, icon) + if (color != null) values.put(key_color, color) + if (values.size() > 0) { + db.update(table_payment_modes, values, "$key_id = ?", arrayOf(id.toString())) + } + } + + fun deletePaymentMode(id: Long) { + val db = writableDatabase + db.delete(table_payment_modes, "$key_id = ?", arrayOf(id.toString())) + } + + fun addCategory(category: DBCategory): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, category.remoteId) + values.put(key_projectid, category.projectId) + values.put(key_name, category.name) + values.put(key_icon, category.icon) + values.put(key_color, category.color) + return db.insert(table_categories, null, values) + } + + fun getCategory(remoteId: Long, projectId: Long): DBCategory? { + val categories = getCategoriesCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projectId.toString()), + null + ) + return if (categories.isEmpty()) null else categories[0] + } + + fun getCategories(projectId: Long): List { + return getCategoriesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) + } + + @WorkerThread + private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getCategoriesCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy) + val categories: MutableList = ArrayList() + while (cursor.moveToNext()) { + categories.add(getCategoryFromCursor(cursor)) + } + cursor.close() + return categories + } + + @SuppressLint("Range") + private fun getCategoryFromCursor(cursor: Cursor): DBCategory { + return DBCategory( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_icon)), + cursor.getString(cursor.getColumnIndex(key_color)) + ) + } + + fun updateCategory(id: Long, name: String?, icon: String?, color: String?) { + val db = writableDatabase + val values = ContentValues() + if (name != null) values.put(key_name, name) + if (icon != null) values.put(key_icon, icon) + if (color != null) values.put(key_color, color) + if (values.size() > 0) { + db.update(table_categories, values, "$key_id = ?", arrayOf(id.toString())) + } + } + + fun deleteCategory(id: Long) { + val db = writableDatabase + db.delete(table_categories, "$key_id = ?", arrayOf(id.toString())) + } + + fun addCurrency(currency: DBCurrency): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, currency.remoteId) + values.put(key_projectid, currency.projectId) + values.put(key_name, currency.name) + values.put(key_exchangeRate, currency.exchangeRate) + values.put(key_state, currency.state) + return db.insert(table_currencies, null, values) + } + + fun addCurrencyAndSync(m: DBCurrency) { + addCurrency(m) + val proj = getProject(m.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun syncIfRemote(proj: DBProject) { + if (!proj.isLocal) { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val offlineMode = preferences.getBoolean(context.getString(R.string.pref_key_offline_mode), false) + if (!offlineMode) { + cowspentServerSyncHelper.scheduleSync(true, proj.id) + } + } + } + + fun getCurrency(remoteId: Long, projectId: Long): DBCurrency? { + val currencies = getCurrenciesCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projectId.toString()), + null + ) + return if (currencies.isEmpty()) null else currencies[0] + } + + fun getCurrency(id: Long): DBCurrency? { + val currencies = getCurrenciesCustom("$key_id = ?", arrayOf(id.toString()), null) + return if (currencies.isEmpty()) null else currencies[0] + } + + fun updateCurrency(id: Long, name: String?, exchangeRate: Double?) { + val db = writableDatabase + val values = ContentValues() + if (name != null) values.put(key_name, name) + if (exchangeRate != null) values.put(key_exchangeRate, exchangeRate) + if (values.size() > 0) { + db.update(table_currencies, values, "$key_id = ?", arrayOf(id.toString())) + } + } + + fun deleteCurrency(id: Long) { + val db = writableDatabase + db.delete(table_currencies, "$key_id = ?", arrayOf(id.toString())) + } + + fun getCurrencies(projectId: Long): List { + return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) + } + + @WorkerThread + private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getCurrenciesCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy) + val currencies: MutableList = ArrayList() + while (cursor.moveToNext()) { + currencies.add(getCurrencyFromCursor(cursor)) + } + cursor.close() + return currencies + } + + @SuppressLint("Range") + private fun getCurrencyFromCursor(cursor: Cursor): DBCurrency { + return DBCurrency( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getDouble(cursor.getColumnIndex(key_exchangeRate)), + cursor.getInt(cursor.getColumnIndex(key_state)) + ) + } + + fun setCurrencyStateSync(currencyId: Long, state: Int) { + setCurrencyState(currencyId, state) + val currency = getCurrency(currencyId) + if (currency != null) { + val project = getProject(currency.projectId) + if (project != null) syncIfRemote(project) + } + } + + fun setCurrencyState(currencyId: Long, state: Int) { + val db = writableDatabase + val values = ContentValues() + values.put(key_state, state) + db.update(table_currencies, values, "$key_id = ?", arrayOf(currencyId.toString())) + } + + fun addProject(project: DBProject): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, project.remoteId) + values.put(key_password, project.password) + values.put(key_bearer_token, project.bearerToken) + values.put(key_email, project.email) + values.put(key_name, project.name) + values.put(key_ihmUrl, project.serverUrl) + values.put(key_type, project.type.id) + values.put(key_archived, project.archivedTs ?: 0L) + return db.insert(table_projects, null, values) + } + + fun getProject(id: Long): DBProject? { + val projects = getProjectsCustom("$key_id = ?", arrayOf(id.toString()), null) + return if (projects.isEmpty()) null else projects[0] + } + + val projects: List + get() = getProjectsCustom("", arrayOf(), default_order) + + @WorkerThread + private fun getProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getProjectsCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_projects, columnsProjects, selection, selectionArgs, null, null, orderBy) + val projects: MutableList = ArrayList() + while (cursor.moveToNext()) { + projects.add(getProjectFromCursor(cursor)) + } + cursor.close() + return projects + } + + @SuppressLint("Range") + private fun getProjectFromCursor(cursor: Cursor): DBProject { + val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) + return DBProject( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getString(cursor.getColumnIndex(key_remoteId)), + cursor.getString(cursor.getColumnIndex(key_password)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_ihmUrl)), + cursor.getString(cursor.getColumnIndex(key_email)), + cursor.getLong(cursor.getColumnIndex(key_lastPayerId)), + ProjectType.getTypeById(cursor.getString(cursor.getColumnIndex(key_type)))!!, + cursor.getLong(cursor.getColumnIndex(key_lastSyncTimestamp)), + cursor.getString(cursor.getColumnIndex(key_currencyName)), + cursor.getInt(cursor.getColumnIndex(key_deletionDisabled)) != 0, + cursor.getInt(cursor.getColumnIndex(key_myAccessLevel)), + cursor.getString(cursor.getColumnIndex(key_bearer_token)), + if (archivedTs > 0) archivedTs else null, + cursor.getLong(cursor.getColumnIndex(key_latest_bill_ts)) + ) + } + + fun deleteProject(id: Long) { + val db = writableDatabase + for (b in getBillsOfProject(id)) { + deleteBill(b.id) + } + db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) + db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) + } + + fun updateProject( + projId: Long, newName: String?, newEmail: String?, + newPassword: String?, newLastPayerId: Long?, + newLastSyncedTimestamp: Long?, + newCurrencyName: String?, newDeletionDisabled: Boolean?, + newMyAccessLevel: Int?, newBearerToken: String?, + newArchivedTs: Long? = null + ) { + val db = writableDatabase + val values = ContentValues() + if (newName != null) values.put(key_name, newName) + if (newEmail != null) values.put(key_email, newEmail) + if (newPassword != null) values.put(key_password, newPassword) + if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) + if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) + if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) + if (newCurrencyName != null) values.put(key_currencyName, newCurrencyName) + if (newDeletionDisabled != null) values.put(key_deletionDisabled, if (newDeletionDisabled) 1 else 0) + if (newMyAccessLevel != null) values.put(key_myAccessLevel, newMyAccessLevel) + if (newArchivedTs != null) values.put(key_archived, newArchivedTs) + if (values.size() > 0) { + db.update(table_projects, values, "$key_id = ?", arrayOf(projId.toString())) + } + } + + fun updateProject( + projId: Long, newName: String?, newEmail: String?, + newPassword: String?, newLastPayerId: Long?, + projectType: ProjectType, newLastSyncedTimestamp: Long?, + newCurrencyName: String?, newDeletionDisabled: Boolean?, + newMyAccessLevel: Int?, newBearerToken: String?, + newArchivedTs: Long? = null + ) { + val db = writableDatabase + updateProject( + projId, newName, newEmail, newPassword, newLastPayerId, projectType, + newLastSyncedTimestamp, newCurrencyName, newDeletionDisabled, newMyAccessLevel, + newBearerToken, newArchivedTs, db + ) + } + + private fun updateProject( + projId: Long, newName: String?, newEmail: String?, + newPassword: String?, newLastPayerId: Long?, + projectType: ProjectType, newLastSyncedTimestamp: Long?, + newCurrencyName: String?, newDeletionDisabled: Boolean?, + newMyAccessLevel: Int?, newBearerToken: String?, + newArchivedTs: Long?, db: SQLiteDatabase + ) { + val values = ContentValues() + if (newName != null) values.put(key_name, newName) + if (newEmail != null) values.put(key_email, newEmail) + if (newPassword != null) values.put(key_password, newPassword) + if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) + if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) + if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) + if (newCurrencyName != null) values.put(key_currencyName, newCurrencyName) + if (newDeletionDisabled != null) values.put(key_deletionDisabled, if (newDeletionDisabled) 1 else 0) + if (newMyAccessLevel != null) values.put(key_myAccessLevel, newMyAccessLevel) + if (newArchivedTs != null) values.put(key_archived, newArchivedTs) + values.put(key_type, projectType.id) + if (values.size() > 0) { + db.update(table_projects, values, "$key_id = ?", arrayOf(projId.toString())) + } + } + + fun addMember(m: DBMember): Long { + if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add member]") } + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, m.remoteId) + values.put(key_projectid, m.projectId) + values.put(key_name, m.name) + values.put(key_activated, if (m.isActivated) "1" else "0") + values.put(key_weight, m.weight) + values.put(key_state, m.state) + values.put(key_r, m.r) + values.put(key_g, m.g) + values.put(key_b, m.b) + values.put(key_nc_userid, m.ncUserId) + values.put(key_avatar, m.avatar) + return db.insert(table_members, null, values) + } + + fun addMemberAndSync(m: DBMember) { + addMember(m) + val proj = getProject(m.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun updateMember( + memberId: Long, newName: String?, newWeight: Double?, + newActivated: Boolean?, newState: Int?, newRemoteId: Long?, + newR: Int?, newG: Int?, newB: Int?, + newNcUserId: String?, newAvatar: String? + ) { + val db = writableDatabase + val values = ContentValues() + if (newName != null) values.put(key_name, newName) + if (newWeight != null) values.put(key_weight, newWeight) + if (newRemoteId != null) values.put(key_remoteId, newRemoteId) + if (newActivated != null) values.put(key_activated, if (newActivated) 1 else 0) + if (newState != null) values.put(key_state, newState) + if (newR != null) values.put(key_r, newR) + if (newG != null) values.put(key_g, newG) + if (newB != null) values.put(key_b, newB) + if (newNcUserId != null) values.put(key_nc_userid, newNcUserId) + if (newAvatar != null) values.put(key_avatar, newAvatar) + if (values.size() > 0) { + db.update(table_members, values, "$key_id = ?", arrayOf(memberId.toString())) + } + } + + fun updateMemberAndSync( + m: DBMember, newName: String?, newWeight: Double?, + newActivated: Boolean?, + newR: Int?, newG: Int?, newB: Int?, + newNcUserId: String?, newAvatar: String? + ) { + val newState = if (m.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED + updateMember( + m.id, newName, newWeight, newActivated, newState, null, + newR, newG, newB, newNcUserId, newAvatar + ) + Log.v(TAG, "UPDATE MEMBER AND SYNC") + val proj = getProject(m.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun getMembersOfProject(projId: Long, orderByParam: String?): List { + val orderBy = orderByParam ?: "LOWER($key_name)" + return getMembersCustom("$key_projectid = ?", arrayOf(projId.toString()), "$orderBy ASC") + } + + fun getMembersOfProjectWithState(projId: Long, state: Int): List { + return getMembersCustom( + "$key_projectid = ? AND $key_state = ?", + arrayOf(projId.toString(), state.toString()), + "$key_name ASC" + ) + } + + fun getActivatedMembersOfProject(projId: Long): List { + return getMembersCustom( + "$key_projectid = ? AND $key_activated = 1", + arrayOf(projId.toString()), + "$key_name ASC" + ) + } + + fun getMember(remoteId: Long, projId: Long): DBMember? { + val members = getMembersCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projId.toString()), + null + ) + return if (members.isEmpty()) null else members[0] + } + + fun getMember(id: Long): DBMember? { + val members = getMembersCustom("$key_id = ?", arrayOf(id.toString()), null) + return if (members.isEmpty()) null else members[0] + } + + @WorkerThread + private fun getMembersCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + val db = readableDatabase + val cursor = db.query(table_members, columnsMembers, selection, selectionArgs, null, null, orderBy) + val members: MutableList = ArrayList() + while (cursor.moveToNext()) { + members.add(getMemberFromCursor(cursor)) + } + cursor.close() + return members + } + + @SuppressLint("Range") + private fun getMemberFromCursor(cursor: Cursor): DBMember { + return DBMember( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getInt(cursor.getColumnIndex(key_activated)) == 1, + cursor.getDouble(cursor.getColumnIndex(key_weight)), + cursor.getInt(cursor.getColumnIndex(key_state)), + if (cursor.isNull(cursor.getColumnIndex(key_r))) null else cursor.getInt(cursor.getColumnIndex(key_r)), + if (cursor.isNull(cursor.getColumnIndex(key_g))) null else cursor.getInt(cursor.getColumnIndex(key_g)), + if (cursor.isNull(cursor.getColumnIndex(key_b))) null else cursor.getInt(cursor.getColumnIndex(key_b)), + if (cursor.isNull(cursor.getColumnIndex(key_nc_userid))) null else cursor.getString(cursor.getColumnIndex(key_nc_userid)), + if (cursor.isNull(cursor.getColumnIndex(key_avatar))) null else cursor.getString(cursor.getColumnIndex(key_avatar)) + ) + } + + fun deleteMember(id: Long) { + val db = writableDatabase + db.delete(table_members, "$key_id = ?", arrayOf(id.toString())) + } + + fun addBill(b: DBBill): Long { + if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add bill]") } + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, b.remoteId) + values.put(key_projectid, b.projectId) + values.put(key_payer_id, b.payerId) + values.put(key_amount, b.amount) + values.put(key_timestamp, b.timestamp) + values.put(key_what, b.what) + values.put(key_state, b.state) + values.put(key_repeat, b.repeat) + values.put(key_payment_mode, b.paymentMode) + values.put(key_payment_mode_id, b.paymentModeRemoteId) + values.put(key_category_id, b.categoryRemoteId) + values.put(key_comment, b.comment) + val billId = db.insert(table_bills, null, values) + for (bo in b.billOwers) { + addBillower(billId, bo.memberId) + } + return billId + } + + fun setBillState(billId: Long, state: Int) { + val db = writableDatabase + val values = ContentValues() + values.put(key_state, state) + db.update(table_bills, values, "$key_id = ?", arrayOf(billId.toString())) + } + + fun updateBill( + billId: Long, newRemoteId: Long?, newPayerId: Long?, + newAmount: Double?, newTimestamp: Long?, + newWhat: String?, newState: Int?, + newRepeat: String?, + newPaymentMode: String?, newPaymentModeRemoteId: Int?, + newCategoryId: Int?, newComment: String? + ) { + val db = writableDatabase + val values = ContentValues() + if (newTimestamp != null) values.put(key_timestamp, newTimestamp) + if (newWhat != null) values.put(key_what, newWhat) + if (newRemoteId != null) values.put(key_remoteId, newRemoteId) + if (newPayerId != null) values.put(key_payer_id, newPayerId) + if (newAmount != null) values.put(key_amount, newAmount) + if (newState != null) values.put(key_state, newState) + if (newRepeat != null) values.put(key_repeat, newRepeat) + if (newPaymentMode != null) values.put(key_payment_mode, newPaymentMode) + if (newPaymentModeRemoteId != null) values.put(key_payment_mode_id, newPaymentModeRemoteId) + if (newCategoryId != null) values.put(key_category_id, newCategoryId) + if (newComment != null) values.put(key_comment, newComment) + if (values.size() > 0) { + db.update(table_bills, values, "$key_id = ?", arrayOf(billId.toString())) + } + } + + fun updateBillAndSync( + bill: DBBill, newPayerId: Long, newAmount: Double, + newTimestamp: Long?, newWhat: String?, + newOwersIds: List?, newRepeat: String?, + newPaymentMode: String?, newPaymentModeRemoteId: Int?, + newCategoryId: Int?, + newComment: String? + ) { + val newState = if (bill.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED + updateBill( + bill.id, null, newPayerId, newAmount, newTimestamp, newWhat, newState, + newRepeat, newPaymentMode, newPaymentModeRemoteId, newCategoryId, newComment + ) + val dbBillOwers = getBillowersOfBill(bill.id) + val dbBillOwersByMemberId: MutableMap = HashMap() + for (bo in dbBillOwers) { + dbBillOwersByMemberId[bo.memberId] = bo + } + if (newOwersIds != null) { + for (newOwerId in newOwersIds) { + if (!dbBillOwersByMemberId.containsKey(newOwerId)) { + addBillower(bill.id, newOwerId) + } + } + for (dbBo in dbBillOwers) { + if (!newOwersIds.contains(dbBo.memberId)) { + deleteBillOwer(dbBo.id) + } + } + } + Log.v(TAG, "UPDATE BILL AND SYNC") + val proj = getProject(bill.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun getBillsOfProject(projId: Long): List { + return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC") + } + + fun getBillsOfProjectWithState(projId: Long, state: Int): List { + return getBillsCustom( + "$key_projectid = ? AND $key_state = ?", + arrayOf(projId.toString(), state.toString()), + "$key_timestamp ASC" + ) + } + + fun getBillsOfMember(memberId: Long): List { + return getBillsCustom("$key_payer_id = ?", arrayOf(memberId.toString()), "$key_timestamp ASC") + } + + @Suppress("unused") + fun getBill(remoteId: Long, projId: Long): DBBill? { + val bills = getBillsCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projId.toString()), + null + ) + return if (bills.isEmpty()) null else bills[0] + } + + fun getBill(billId: Long): DBBill? { + val bills = getBillsCustom("$key_id = ?", arrayOf(billId.toString()), null) + return if (bills.isEmpty()) null else bills[0] + } + + fun getCurrenciesOfProjectWithState(projId: Long, state: Int): List { + return getCurrenciesCustom( + "$key_projectid = ? AND $key_state = ?", + arrayOf(projId.toString(), state.toString()), + null + ) + } + + @WorkerThread + fun searchBills(query: CharSequence?, projectId: Long): List { + val andWhere: MutableList = ArrayList() + val args: MutableList = ArrayList() + andWhere.add("($key_projectid = $projectId)") + andWhere.add("($key_state != ${DBBill.STATE_DELETED})") + if (query != null) { + args.add("%$query%") + var whereStr = "($key_what LIKE ?" + if (SupportUtil.isDouble(query.toString())) { + whereStr += " OR ($key_amount <= (? + 10) AND $key_amount >= (? - 10))" + args.add(query.toString()) + args.add(query.toString()) + } + val members = getMembersOfProject(projectId, null) + val memberNames: MutableList = ArrayList() + val memberIds: MutableList = ArrayList() + for (m in members) { + memberNames.add(m.name.lowercase(Locale.ROOT)) + memberIds.add(m.id) + } + val queryStr = query.toString() + val words = queryStr.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + var nameSql = "" + for (word in words) { + if (word.startsWith("+")) { + val nameQuery = word.replace("^\\+".toRegex(), "") + val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT)) + if (memberIndex != -1) { + val searchMemberId = memberIds[memberIndex] + nameSql += "($key_payer_id=?) AND " + args.add(searchMemberId.toString()) + } + } + if (word.startsWith("-")) { + val nameQuery = word.replace("^-".toRegex(), "") + val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT)) + if (memberIndex != -1) { + val searchMemberId = memberIds[memberIndex] + val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " + + "where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId" + nameSql += "($key_id IN ($joinOwer)) AND " + args.add(searchMemberId.toString()) + } + } + if (word.startsWith("@")) { + val nameQuery = word.replace("^@".toRegex(), "") + val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT)) + if (memberIndex != -1) { + val searchMemberId = memberIds[memberIndex] + nameSql += "( ($key_payer_id=?) OR " + args.add(searchMemberId.toString()) + val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " + + "where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId" + nameSql += "($key_id IN ($joinOwer)) ) AND " + args.add(searchMemberId.toString()) + } + } + } + if (nameSql != "") { + nameSql = nameSql.replace(" AND $".toRegex(), "") + whereStr += " OR ($nameSql)" + } + whereStr += ")" + andWhere.add(whereStr) + } + val order = "$key_timestamp DESC" + return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), order) + } + + @WorkerThread + private fun getBillsCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + val db = readableDatabase + val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy) + val bills: MutableList = ArrayList() + while (cursor.moveToNext()) { + val bill = getBillFromCursor(cursor) + bill.billOwers = getBillowersOfBill(bill.id) + bills.add(bill) + } + cursor.close() + return bills + } + + @SuppressLint("Range") + private fun getBillFromCursor(cursor: Cursor): DBBill { + return DBBill( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getLong(cursor.getColumnIndex(key_payer_id)), + cursor.getDouble(cursor.getColumnIndex(key_amount)), + cursor.getLong(cursor.getColumnIndex(key_timestamp)), + cursor.getString(cursor.getColumnIndex(key_what)), + cursor.getInt(cursor.getColumnIndex(key_state)), + cursor.getString(cursor.getColumnIndex(key_repeat)), + cursor.getString(cursor.getColumnIndex(key_payment_mode)), + cursor.getInt(cursor.getColumnIndex(key_category_id)), + cursor.getString(cursor.getColumnIndex(key_comment)), + cursor.getInt(cursor.getColumnIndex(key_payment_mode_id)) + ) + } + + fun deleteBill(id: Long) { + val db = writableDatabase + db.delete(table_billowers, "$key_billId = ?", arrayOf(id.toString())) + db.delete(table_bills, "$key_id = ?", arrayOf(id.toString())) + } + + fun addBillower(billId: Long, memberId: Long) { + if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add billower]") } + val db = writableDatabase + val values = ContentValues() + values.put(key_billId, billId) + values.put(key_member_id, memberId) + db.insert(table_billowers, null, values) + } + + fun getBillowersOfBill(billId: Long): List { + return getBillOwersCustom("$key_billId = ?", arrayOf(billId.toString()), null) + } + + fun getBillowersOfMember(memberId: Long): List { + return getBillOwersCustom("$key_member_id = ?", arrayOf(memberId.toString()), null) + } + + @WorkerThread + private fun getBillOwersCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + val db = readableDatabase + val cursor = db.query(table_billowers, columnsBillowers, selection, selectionArgs, null, null, orderBy) + val billOwers: MutableList = ArrayList() + while (cursor.moveToNext()) { + billOwers.add(getBillOwerFromCursor(cursor)) + } + cursor.close() + return billOwers + } + + @SuppressLint("Range") + private fun getBillOwerFromCursor(cursor: Cursor): DBBillOwer { + return DBBillOwer( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_billId)), + cursor.getLong(cursor.getColumnIndex(key_member_id)) + ) + } + + fun deleteBillOwer(id: Long) { + val db = writableDatabase + db.delete(table_billowers, "$key_id = ?", arrayOf(id.toString())) + } + + @Suppress("ConstPropertyName") + companion object { + private val TAG = CowspentSQLiteOpenHelper::class.java.simpleName + private const val database_version = 20 + private const val database_name = "COWSPENT" + private const val table_members = "MEMBERS" + const val key_id = "ID" + const val key_remoteId = "REMOTEID" + private const val key_projectid = "PROJECTID" + const val key_name = "NAME" + private const val key_activated = "ACTIVATED" + private const val key_weight = "WEIGHT" + private const val key_state = "STATE" + private const val key_r = "R" + private const val key_g = "G" + private const val key_b = "B" + const val key_nc_userid = "NCUSERID" + const val key_avatar = "AVATAR" + private const val table_projects = "PROJECTS" + private const val key_email = "EMAIL" + private const val key_password = "PASSWORD" + private const val key_bearer_token = "BEARERTOKEN" + private const val key_ihmUrl = "IHMURL" + private const val key_lastPayerId = "LASTPAYERID" + private const val key_type = "TYPE" + private const val key_lastSyncTimestamp = "LASTSYNCED" + private const val key_currencyName = "CURRENCYNAME" + private const val key_deletionDisabled = "DELETIONDISABLED" + private const val key_myAccessLevel = "MYACCESSLEVEL" + private const val key_archived = "ARCHIVED" + private const val table_bills = "BILLS" + private const val key_payer_id = "PAYERID" + private const val key_amount = "AMOUNT" + private const val key_timestamp = "TIMESTAMP" + private const val key_what = "WHAT" + private const val key_repeat = "REPEAT" + private const val key_payment_mode = "PAYMENTMODE" + private const val key_payment_mode_id = "PAYMENTMODEID" + private const val key_category_id = "CATEGORYID" + private const val key_comment = "COMMENT" + private const val table_billowers = "BILLOWERS" + private const val key_billId = "BILLID" + private const val key_member_id = "MEMBERID" + private const val table_account_projects = "ACCOUNTPROJECTS" + private const val key_ncUrl = "NCURL" + private const val table_categories = "CATEGORIES" + private const val key_icon = "ICON" + private const val key_color = "COLOR" + private const val table_payment_modes = "PAYMENTMODES" + private const val key_latest_bill_ts = "LATEST_BILL_TS" + private const val table_currencies = "CURRENCIES" + private const val key_exchangeRate = "EXCHANGERATE" + + private val columnsMembers = arrayOf( + key_id, key_remoteId, key_projectid, key_name, key_activated, key_weight, key_state, + key_r, key_g, key_b, key_nc_userid, key_avatar + ) + private val columnsProjects = arrayOf( + key_id, key_remoteId, key_password, key_name, key_ihmUrl, + key_email, key_lastPayerId, key_type, key_lastSyncTimestamp, key_currencyName, + key_deletionDisabled, key_myAccessLevel, key_bearer_token, key_archived, + "(SELECT MAX($key_timestamp) FROM $table_bills WHERE $key_projectid = $table_projects.$key_id AND $key_state != ${DBBill.STATE_DELETED}) AS $key_latest_bill_ts" + ) + private val columnsBills = arrayOf( + key_id, key_remoteId, key_projectid, key_payer_id, key_amount, + key_timestamp, key_what, key_state, key_repeat, key_payment_mode, key_category_id, + key_comment, key_payment_mode_id + ) + private val columnsBillowers = arrayOf( + key_id, key_billId, key_member_id + ) + private val columnsAccountProjects = arrayOf( + key_id, key_remoteId, key_password, key_name, key_ncUrl, key_archived + ) + private val columnsCategories = arrayOf( + key_id, key_remoteId, key_projectid, key_name, key_icon, key_color + ) + private val columnsPaymentModes = arrayOf( + key_id, key_remoteId, key_projectid, key_name, key_icon, key_color + ) + private val columnsCurrencies = arrayOf( + key_id, key_remoteId, key_projectid, key_name, key_exchangeRate, key_state + ) + private const val default_order = "$key_id DESC" + + @Volatile + private var instance: CowspentSQLiteOpenHelper? = null + + @JvmStatic + fun getInstance(context: Context): CowspentSQLiteOpenHelper { + return instance ?: synchronized(this) { + instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt new file mode 100644 index 0000000..fab690b --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -0,0 +1,1501 @@ +package net.helcel.cowspent.persistence + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.SharedPreferences +import android.net.ConnectivityManager +import android.os.IBinder +import android.util.Log +import androidx.core.content.edit +import androidx.core.graphics.toColorInt +import androidx.preference.PreferenceManager +import com.google.gson.GsonBuilder +import com.nextcloud.android.sso.api.NextcloudAPI +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException +import com.nextcloud.android.sso.exceptions.TokenMismatchException +import com.nextcloud.android.sso.helper.SingleAccountHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.helcel.cowspent.R +import net.helcel.cowspent.android.account.AccountActivity +import net.helcel.cowspent.android.main.BillsListViewActivity +import net.helcel.cowspent.android.main.MainConstants +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType +import net.helcel.cowspent.util.CospendClientUtil.LoginStatus +import net.helcel.cowspent.util.ICallback +import net.helcel.cowspent.util.IProjectCreationCallback +import net.helcel.cowspent.util.NextcloudClient +import net.helcel.cowspent.util.ServerResponse +import net.helcel.cowspent.util.SupportUtil +import net.helcel.cowspent.util.VersatileProjectSyncClient +import org.json.JSONException +import java.io.IOException + +@Suppress("DEPRECATION") +class CowspentServerSyncHelper private constructor(private val dbHelper: CowspentSQLiteOpenHelper) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val appContext: Context = dbHelper.context.applicationContext + private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(dbHelper.context) + private var networkConnected = false + + private val certService = object : ServiceConnection { + override fun onServiceConnected(componentName: ComponentName, iBinder: IBinder) { + if (isSyncPossible) { + val lastId = PreferenceManager.getDefaultSharedPreferences(dbHelper.context).getLong("selected_project", 0) + if (lastId != 0L) { + val proj = dbHelper.getProject(lastId) + if (proj != null) { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_SYNC_PROJECT)) + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_NETWORK_AVAILABLE)) + } + } + } + } + + override fun onServiceDisconnected(p0: ComponentName?) { + } + } + + private var syncActive = false + private var syncAccountProjectsActive = false + + private var callbacksPush: MutableList = ArrayList() + private var callbacksPull: MutableList = ArrayList() + + init { + + updateNetworkStatus() + } + + @Throws(Throwable::class) + protected fun finalize() { + appContext.unbindService(certService) + } + + val isSyncPossible: Boolean + get() { + updateNetworkStatus() + return networkConnected + } + + fun addCallbackPull(callback: ICallback) { + callbacksPull.add(callback) + } + + fun scheduleSync(onlyLocalChanges: Boolean, projId: Long): SyncTask? { + Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}) ...") + updateNetworkStatus() + if (isSyncPossible && (!syncActive || onlyLocalChanges)) { + val project = dbHelper.getProject(projId) + if (project != null) { + Log.d(TAG, "... starting now") + val syncTask = SyncTask(onlyLocalChanges, project) + syncTask.addCallbacks(callbacksPush) + callbacksPush = ArrayList() + if (!onlyLocalChanges) { + syncTask.addCallbacks(callbacksPull) + callbacksPull = ArrayList() + } + return syncTask.execute() + } else { + Log.d(TAG, "sync asked for project $projId which does not exist : DOING NOTHING") + } + } else if (!onlyLocalChanges) { + Log.d(TAG, "... scheduled") + projectIdsToSync.add(projId) + for (callback in callbacksPush) { + callback.onScheduled() + } + } else { + Log.d(TAG, "... do nothing") + for (callback in callbacksPush) { + callback.onScheduled() + } + } + return null + } + + private fun updateNetworkStatus() { + val connMgr = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeInfo = connMgr.activeNetworkInfo + if (activeInfo != null && activeInfo.isConnected) { + Log.d(TAG, "Network connection established.") + networkConnected = true + } else { + networkConnected = false + Log.d(TAG, "No network connection.") + } + } + + inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject) { + private val callbacks: MutableList = ArrayList() + private var nextcloudClient: NextcloudClient? = null + private var client: VersatileProjectSyncClient? = null + private val exceptions: MutableList = ArrayList() + private val errorMessages: MutableList = ArrayList() + private var nbPulledNewBills = 0 + private var nbPulledUpdatedBills = 0 + private var nbPulledDeletedBills = 0 + private var newBillsDialogText = "" + private var updatedBillsDialogText = "" + private var deletedBillsDialogText = "" + + private var deferred: Deferred? = null + + init { + Log.i(TAG, "SYNC TASK project : ${project.remoteId}") + } + + fun addCallbacks(callbacks: List) { + this.callbacks.addAll(callbacks) + } + + fun execute(): SyncTask { + deferred = scope.async { + syncActive = true + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + syncActive = false + status + } + return this + } + + private fun doWork(): LoginStatus { + var version: String? = null + if (project.type == ProjectType.COSPEND) { + nextcloudClient = createNextcloudClient() + if (nextcloudClient != null) { + try { + val response = nextcloudClient!!.getCapabilities(project) + version = response.cospendVersion + } catch (e: Exception) { + Log.i(TAG, "Failed to get cospend version when syncing: $e") + } + } else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + return LoginStatus.SSO_TOKEN_MISMATCH + } + } + + Log.i(TAG, "Syncing, cospend version is: $version") + + client = createVersatileProjectSyncClient(version) + if (client == null) { + return LoginStatus.CONNECTION_FAILED + } + Log.i(TAG, "STARTING SYNCHRONIZATION with Cospend version($version)") + var status = pushLocalChanges() + if (status == LoginStatus.OK) { + status = pullRemoteChanges() + } + Log.i(TAG, "SYNCHRONIZATION FINISHED") + return status + } + + fun get(): LoginStatus = runBlocking { + deferred?.await() ?: LoginStatus.CONNECTION_FAILED + } + + private fun pushLocalChanges(): LoginStatus { + Log.d(TAG, "PUSH LOCAL CHANGES") + + return try { + val membersResponse = client!!.getMembers(project) + val remoteMembers = membersResponse.getMembers(project.id) + val remoteMembersNames = remoteMembers.map { it.name } + + val membersToAdd = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (mToAdd in membersToAdd) { + val searchIndex = remoteMembersNames.indexOf(mToAdd.name) + if (searchIndex != -1) { + val remoteMember = remoteMembers[searchIndex] + dbHelper.updateMember( + mToAdd.id, null, + remoteMember.weight, remoteMember.isActivated, + DBBill.STATE_OK, remoteMember.remoteId, remoteMember.r, + remoteMember.g, remoteMember.b, + remoteMember.ncUserId, "" + ) + } else { + val createRemoteMemberResponse = client!!.createRemoteMember(project, mToAdd) + val newRemoteId = createRemoteMemberResponse.remoteMemberId + if (newRemoteId > 0) { + dbHelper.updateMember( + mToAdd.id, null, + null, null, DBBill.STATE_OK, newRemoteId, + null, null, null, null, null + ) + } + } + } + + val membersToEdit = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (mToEdit in membersToEdit) { + try { + val editRemoteMemberResponse = client!!.editRemoteMember(project, mToEdit) + val remoteId = editRemoteMemberResponse.getRemoteId(project.id) + if (remoteId == mToEdit.remoteId) { + dbHelper.updateMember( + mToEdit.id, null, + null, null, DBBill.STATE_OK, null, + null, null, null, null, null + ) + } + } catch (e: IOException) { + if (e.message == "{\"message\": \"Internal Server Error\"}") { + Log.d(TAG, "EDIT MEMBER FAILED : it does not exist remotely") + } else { + throw e + } + } + } + + val members = dbHelper.getMembersOfProject(project.id, null) + val memberIdToRemoteId = members.associate { it.id to it.remoteId } + + val toDelete = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (bToDel in toDelete) { + try { + val deleteRemoteBillResponse = client!!.deleteRemoteBill(project, bToDel.remoteId) + if (deleteRemoteBillResponse.stringContent == "OK") { + Log.d(TAG, "successfully deleted bill on remote project : delete it locally") + dbHelper.deleteBill(bToDel.id) + } + } catch (e: IOException) { + if (e.message == "\"Not Found\"") { + Log.d(TAG, "failed to delete bill on remote project : delete it locally anyway") + dbHelper.deleteBill(bToDel.id) + } else { + throw e + } + } catch (e: NextcloudHttpRequestFailedException) { + if (e.statusCode == 404) { + Log.d(TAG, "failed to delete bill on remote project : delete it locally anyway") + dbHelper.deleteBill(bToDel.id) + } else { + throw e + } + } + } + + val toEdit = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (bToEdit in toEdit) { + try { + val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId) + if (editRemoteBillResponse.stringContent == bToEdit.remoteId.toString()) { + dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK) + Log.d(TAG, "SUCCESSFUL remote bill edition (${editRemoteBillResponse.stringContent})") + } else { + Log.d(TAG, "FAILED to edit remote bill (${editRemoteBillResponse.stringContent})") + } + } catch (_: Exception) { + Log.d(TAG, "FAILED to edit remote bill: it probably does not exist remotely") + } + } + + val toAdd = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (bToAdd in toAdd) { + val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId) + val newRemoteId = createRemoteBillResponse.stringContent.toLong() + if (newRemoteId > 0) { + dbHelper.updateBill( + bToAdd.id, newRemoteId, null, + null, null, null, + DBBill.STATE_OK, null, + null, null, + null, null + ) + } + } + + val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (cToDel in currenciesToDelete) { + try { + val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId) + if (deleteRemoteCurrencyResponse.stringContent == "OK") { + Log.d(TAG, "successfully deleted currency on remote project : delete it locally") + dbHelper.deleteCurrency(cToDel.id) + } + } catch (e: IOException) { + if (e.message == "\"Not Found\"") { + Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway") + dbHelper.deleteCurrency(cToDel.id) + } else { + throw e + } + } + } + + val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (cToEdit in currenciesToEdit) { + try { + val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit) + if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) { + dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) + Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})") + } else { + Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})") + } + } catch (e: IOException) { + if (e.message == "{\"message\": \"Internal Server Error\"}") { + Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely") + } else { + throw e + } + } + } + + val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (cToAdd in currencyToAdd) { + val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) + val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() + if (newRemoteId > 0) { + dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) + } + } + + LoginStatus.OK + } catch (_: ServerResponse.NotModifiedException) { + Log.d(TAG, "No changes, nothing to do.") + LoginStatus.OK + } catch (e: IOException) { + Log.e(TAG, "Exception", e) + exceptions.add(e) + LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + Log.e(TAG, "Exception", e) + exceptions.add(e) + LoginStatus.JSON_FAILED + } catch (e: TokenMismatchException) { + Log.e(TAG, "Catch MISMATCHTOKEN", e) + LoginStatus.SSO_TOKEN_MISMATCH + } catch (e: NextcloudHttpRequestFailedException) { + Log.e(TAG, "Catch SSO HTTP req FAILED", e) + errorMessages.add(getErrorMessageFromException(e)) + e.cause?.let { exceptions.add(it) } + LoginStatus.REQ_FAILED + } + } + + private fun pullRemoteChanges(): LoginStatus { + Log.d(TAG, "pullRemoteChanges($project)") + val lastETag: String? = null + val lastModified: Long = 0 + return try { + val projResponse = client!!.getProject(project, lastModified, lastETag) + Log.d(TAG,projResponse.toString()) + val name = projResponse.name + Log.i(TAG, "AAA getProjectInfo, project name: $name") + val email = projResponse.email + val currencyName = projResponse.currencyName + val deletionDisabled = projResponse.deletionDisabled + val myAccessLevel = projResponse.myAccessLevel + val archivedTs = projResponse.archivedTs + + if (project.name == "" || name != project.name || project.email == null || project.email == "" || project.isDeletionDisabled != deletionDisabled || project.myAccessLevel != myAccessLevel || project.archivedTs != archivedTs || (project.currencyName == null) || (currencyName != project.currencyName) || email != project.email + ) { + Log.d(TAG, "update local project : $project") + project.name = name + project.currencyName = currencyName + project.isDeletionDisabled = deletionDisabled + project.myAccessLevel = myAccessLevel + project.archivedTs = archivedTs + dbHelper.updateProject( + projId = project.id, + newName = name, + newEmail = email, + newPassword = null, + newLastPayerId = null, + newLastSyncedTimestamp = null, + newCurrencyName = currencyName, + newDeletionDisabled = deletionDisabled, + newMyAccessLevel = myAccessLevel, + newBearerToken = null, + newArchivedTs = archivedTs ?: 0L + ) + } + + val remotePaymentModes = projResponse.getPaymentModes(project.id) + val remotePaymentModesByRemoteId = remotePaymentModes.associateBy { it.remoteId } + + for (pm in remotePaymentModes) { + val localPaymentMode = dbHelper.getPaymentMode(pm.remoteId, project.id) + if (localPaymentMode == null) { + Log.d(TAG, "Add local pm : $pm") + dbHelper.addPaymentMode(pm) + } else { + if (pm.name == localPaymentMode.name && + pm.color == localPaymentMode.color && + pm.icon == localPaymentMode.icon + ) { + Log.d(TAG, "Nothing to do for pm : $localPaymentMode") + } else { + Log.d(TAG, "Update local pm : $pm") + dbHelper.updatePaymentMode(localPaymentMode.id, pm.name, pm.icon, pm.color) + } + } + } + + val localPaymentModes = dbHelper.getPaymentModes(project.id) + for (localPaymentMode in localPaymentModes) { + if (!remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) { + dbHelper.deletePaymentMode(localPaymentMode.id) + Log.d(TAG, "Delete local pm : $localPaymentMode") + } + } + + val remoteCategories = projResponse.getCategories(project.id) + val remoteCategoriesByRemoteId = remoteCategories.associateBy { it.remoteId } + + for (c in remoteCategories) { + val localCategory = dbHelper.getCategory(c.remoteId, project.id) + if (localCategory == null) { + Log.d(TAG, "Add local category : $c") + dbHelper.addCategory(c) + } else { + if (c.name == localCategory.name && + c.color == localCategory.color && + c.icon == localCategory.icon + ) { + Log.d(TAG, "Nothing to do for category : $localCategory") + } else { + Log.d(TAG, "Update local category : $c") + dbHelper.updateCategory(localCategory.id, c.name, c.icon, c.color) + } + } + } + + val localCategories = dbHelper.getCategories(project.id) + for (localCategory in localCategories) { + if (!remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) { + dbHelper.deleteCategory(localCategory.id) + Log.d(TAG, "Delete local category : $localCategory") + } + } + + val remoteCurrencies = projResponse.getCurrencies(project.id) + val remoteCurrenciesByRemoteId = remoteCurrencies.associateBy { it.remoteId } + + for (c in remoteCurrencies) { + val localCurrency = dbHelper.getCurrency(c.remoteId, project.id) + if (localCurrency == null) { + Log.d(TAG, "Add local currency : $c") + dbHelper.addCurrency(c) + } else { + if (c.name == localCurrency.name && + c.exchangeRate == localCurrency.exchangeRate + ) { + Log.d(TAG, "Nothing to do for currency : $localCurrency") + } else { + Log.d(TAG, "Update local currency : $c") + dbHelper.updateCurrency(localCurrency.id, c.name, c.exchangeRate) + } + } + } + + val localCurrencies = dbHelper.getCurrencies(project.id) + for (localCurrency in localCurrencies) { + if (!remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) { + dbHelper.deleteCurrency(localCurrency.id) + Log.d(TAG, "Delete local currency : $localCurrencies") + } + } + + val remoteMembers = projResponse.getMembers(project.id) + val remoteMembersByRemoteId = remoteMembers.associateBy { it.remoteId } + + for (m in remoteMembers) { + val localMember = dbHelper.getMember(m.remoteId, project.id) + if (localMember == null) { + Log.d(TAG, "Add local member : $m") + val mid = dbHelper.addMember(m) + if (!m.ncUserId.isNullOrEmpty()) { + updateMemberAvatar(mid) + } + } else { + val ncUserIdChanged = ( + (m.ncUserId == null && localMember.ncUserId != null) || + (m.ncUserId != null && localMember.ncUserId == null) || + (m.ncUserId != null && m.ncUserId != localMember.ncUserId) + ) + Log.e("PULLREMOTE", "member NC user id : ${localMember.ncUserId} => ${m.ncUserId} ID changed $ncUserIdChanged") + if (ncUserIdChanged && m.ncUserId == null) { + m.ncUserId = "" + } + if (m.name == localMember.name && + m.weight == localMember.weight && + m.isActivated == localMember.isActivated && + ((m.r == null && m.g == null && m.b == null) || + (m.r == localMember.r && m.g == localMember.g && m.b == localMember.b)) && + !ncUserIdChanged + ) { + Log.d(TAG, "Nothing to do for member : $localMember") + if (!localMember.ncUserId.isNullOrEmpty() && localMember.avatar.isNullOrEmpty()) { + Log.d(TAG, "except updating avatar") + updateMemberAvatar(localMember.id) + } + } else { + Log.d(TAG, "Update local member : $m") + var r = m.r + var g = m.g + var b = m.b + if (m.r == null && m.g == null && m.b == null) { + r = localMember.r + g = localMember.g + b = localMember.b + } + val needAvatarUpdate = (ncUserIdChanged && !m.ncUserId.isNullOrEmpty()) + val newAvatar = if (ncUserIdChanged) "" else null + dbHelper.updateMember( + localMember.id, m.name, m.weight, + m.isActivated, null, null, + r, g, b, m.ncUserId, newAvatar + ) + if (needAvatarUpdate) { + Log.e("PLOP", "pullremote : update member avatar") + updateMemberAvatar(localMember.id) + } + } + } + } + + val dbMembers = dbHelper.getMembersOfProject(project.id, null) + val memberRemoteIdToId = dbMembers.associate { it.remoteId to it.id } + + val billsResponse = client!!.getBills(project) + val isIHM = project.type == ProjectType.IHATEMONEY + val serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp + val remoteBills: List = if (isIHM) { + billsResponse.getBillsIHM(project.id, memberRemoteIdToId) + } else { + billsResponse.getBillsCospend(project.id, memberRemoteIdToId) + } + val remoteAllBillIds: List = if (isIHM) { + remoteBills.map { it.remoteId } + } else { + billsResponse.allBillIds + } + + val remoteBillsByRemoteId = remoteBills.associateBy { it.remoteId } + val localBills = dbHelper.getBillsOfProject(project.id) + val localBillsByRemoteId = localBills.associateBy { it.remoteId } + + for (remoteBill in remoteBills) { + if (!localBillsByRemoteId.containsKey(remoteBill.remoteId)) { + dbHelper.addBill(remoteBill) + nbPulledNewBills++ + newBillsDialogText += "+ ${remoteBill.what}\n" + Log.d(TAG, "Add local bill : $remoteBill") + } else { + val localBill = localBillsByRemoteId[remoteBill.remoteId]!! + if (hasChanged(localBill, remoteBill)) { + dbHelper.updateBill( + localBill.id, null, remoteBill.payerId, + remoteBill.amount, remoteBill.timestamp, + remoteBill.what, DBBill.STATE_OK, remoteBill.repeat, + remoteBill.paymentMode, remoteBill.paymentModeRemoteId, + remoteBill.categoryRemoteId, remoteBill.comment + ) + nbPulledUpdatedBills++ + updatedBillsDialogText += "✏ ${remoteBill.what}\n" + Log.d(TAG, "Update local bill : $remoteBill") + } else { + Log.d(TAG, "Nothing to do for bill : $localBill") + } + + val localBillOwersByIds = localBill.billOwers.associateBy { it.memberId } + val remoteBillOwersByIds = remoteBill.billOwers.associateBy { it.memberId } + + for (rbo in remoteBill.billOwers) { + if (!localBillOwersByIds.containsKey(rbo.memberId)) { + dbHelper.addBillower(localBill.id, rbo.memberId) + Log.d(TAG, "Add local billOwer : $rbo") + } + } + for (lbo in localBill.billOwers) { + if (!remoteBillOwersByIds.containsKey(lbo.memberId)) { + dbHelper.deleteBillOwer(lbo.id) + Log.d(TAG, "Delete local billOwer : $lbo") + } + } + } + } + + if (project.type == ProjectType.COSPEND || project.type == ProjectType.IHATEMONEY) { + for (localBill in localBills) { + if (!remoteAllBillIds.contains(localBill.remoteId)) { + dbHelper.deleteBill(localBill.id) + nbPulledDeletedBills++ + deletedBillsDialogText += "🗑 ${localBill.what}\n" + Log.d(TAG, "Delete local bill : $localBill") + } + } + } else { + for (localBill in localBills) { + if (!remoteBillsByRemoteId.containsKey(localBill.remoteId)) { + dbHelper.deleteBill(localBill.id) + nbPulledDeletedBills++ + deletedBillsDialogText += "🗑 ${localBill.what}\n" + Log.d(TAG, "Delete local bill : $localBill") + } + } + } + + val localMembers = dbHelper.getMembersOfProject(project.id, null) + for (localMember in localMembers) { + if (!remoteMembersByRemoteId.containsKey(localMember.remoteId)) { + if (dbHelper.getBillsOfMember(localMember.id).isEmpty() + && dbHelper.getBillowersOfMember(localMember.id).isEmpty() + ) { + dbHelper.deleteMember(localMember.id) + Log.d(TAG, "Delete local member : $localMember") + } else { + Log.d(TAG, "WARNING local member : ${localMember.name} does not exist remotely but is still involved in some bills") + } + } + } + + dbHelper.updateProject( + project.id, null, null, + null, null, serverSyncTimestamp, + null, null, null, + null + ) + LoginStatus.OK + } catch (_: ServerResponse.NotModifiedException) { + Log.d(TAG, "No changes, nothing to do.") + LoginStatus.OK + } catch (e: IOException) { + Log.e(TAG, "Exception", e) + exceptions.add(e) + LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + Log.e(TAG, "Exception", e) + exceptions.add(e) + LoginStatus.JSON_FAILED + } catch (e: TokenMismatchException) { + Log.e(TAG, "Catch MISMATCHTOKEN", e) + LoginStatus.SSO_TOKEN_MISMATCH + } catch (e: NextcloudHttpRequestFailedException) { + Log.e(TAG, "Catch NC REQ failed", e) + errorMessages.add(getErrorMessageFromException(e)) + e.cause?.let { exceptions.add(it) } + LoginStatus.REQ_FAILED + } + } + + private fun onPostExecute(status: LoginStatus) { + if (status != LoginStatus.OK) { + var errorString = "" + for (errorMessage in errorMessages) { + errorString += "$errorMessage\n" + } + errorString += "\n" + for (e in exceptions) { + val obj = SupportUtil.getJsonObject(e.message) + if (obj != null && obj.has("message")) { + try { + errorString += "${obj.getString("message")}\n" + } catch (_: JSONException) { + } + } + } + val intent = Intent(MainConstants.BROADCAST_PROJECT_SYNC_FAILED) + intent.putExtra(MainConstants.BROADCAST_ERROR_MESSAGE, errorString) + intent.putExtra(MainConstants.BROADCAST_PROJECT_ID, project.id) + appContext.sendBroadcast(intent) + if (status == LoginStatus.SSO_TOKEN_MISMATCH) { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_SSO_TOKEN_MISMATCH)) + } + } else { + val intent = Intent(MainConstants.BROADCAST_PROJECT_SYNCED) + intent.putExtra(MainConstants.BROADCAST_EXTRA_PARAM, project.name) + appContext.sendBroadcast(intent) + } + syncActive = false + for (callback in callbacks) { + callback.onFinish() + } + if (projectIdsToSync.isNotEmpty()) { + val pid = projectIdsToSync.removeAt(projectIdsToSync.size - 1) + scheduleSync(false, pid) + } + } + } + + fun getErrorMessageFromException(e: NextcloudHttpRequestFailedException): String { + var message = "" + when (e.statusCode) { + 503 -> message += appContext.getString(R.string.error_maintenance_mode) + 400 -> message += appContext.getString(R.string.error_400) + 401 -> message += appContext.getString(R.string.error_401) + 403 -> message += appContext.getString(R.string.error_403) + 404 -> message += appContext.getString(R.string.error_404) + } + message += "\n" + e.cause?.message + return message + } + + private fun createVersatileProjectSyncClient(cospendVersion: String?): VersatileProjectSyncClient? { + val preferences = PreferenceManager.getDefaultSharedPreferences(appContext) + val useSSO = preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false) + return if (useSSO) { + try { + val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(appContext) + val nextcloudAPI = NextcloudAPI(appContext, ssoAccount, GsonBuilder().create(), apiCallback) + VersatileProjectSyncClient("", "", "", nextcloudAPI, ssoAccount, cospendVersion, appContext) + } catch (_: Exception) { + null + } + } else { + val url = preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS) ?: "" + val username = preferences.getString(AccountActivity.SETTINGS_USERNAME, AccountActivity.DEFAULT_SETTINGS) ?: "" + val password = preferences.getString(AccountActivity.SETTINGS_PASSWORD, AccountActivity.DEFAULT_SETTINGS) ?: "" + VersatileProjectSyncClient(url, username, password, null, null, cospendVersion, appContext) + } + } + + fun canCreateAuthenticatedProject(project: DBProject): Boolean { + val isCospend = ProjectType.COSPEND == project.type + val projUrl = project.serverUrl?.replace("/index.php/apps/cospend", "")?.replace("/+$".toRegex(), "") ?: "" + + val accountUrl = if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + try { + val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(appContext) + ssoAccount.url.replace("/+$".toRegex(), "") + } catch (_: Exception) { + return false + } + } else { + preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS)?.replace("/$".toRegex(), "") ?: "" + } + + Log.v(TAG, "proj url : $projUrl ; account url : $accountUrl") + return isCospend && projUrl == accountUrl + } + + fun editRemoteProject(projId: Long, newName: String?, newEmail: String?, + newPassword: String?, newMainCurrencyName: String?, callback: ICallback): Boolean { + updateNetworkStatus() + if (isSyncPossible) { + EditRemoteProjectTask(projId, newName, newEmail, newPassword, newMainCurrencyName, callback).execute() + return true + } + return false + } + + private inner class EditRemoteProjectTask( + projId: Long, + private val newName: String?, + private val newEmail: String?, + private val newPassword: String?, + private val newMainCurrencyName: String?, + private val callback: ICallback + ) { + private val project: DBProject? = dbHelper.getProject(projId) + private val exceptions: MutableList = ArrayList() + private val errorMessages: MutableList = ArrayList() + + fun execute(): EditRemoteProjectTask { + scope.launch { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } + return this + } + + private fun doWork(): LoginStatus { + val nextcloudClient = createNextcloudClient() + var version: String? = null + if (nextcloudClient != null) { + try { + val response = nextcloudClient.getCapabilities(null) + version = response.cospendVersion + } catch (e: Exception) { + Log.i(TAG, "Failed to get cospend version when syncing: $e") + } + } + val client = createVersatileProjectSyncClient(version) + if (BillsListViewActivity.DEBUG) { + Log.i(TAG, "STARTING edit remote project") + } + var status = LoginStatus.OK + try { + val response = client!!.editRemoteProject( + project!!, newName, newEmail, newPassword, newMainCurrencyName + ) + if (BillsListViewActivity.DEBUG) { + Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}") + } + } catch (e: IOException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "Exception", e) + exceptions.add(e) + status = LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + Log.e(TAG, "Catch JSON exception", e) + status = LoginStatus.JSON_FAILED + } catch (e: TokenMismatchException) { + Log.e(TAG, "Catch MISMATCHTOKEN", e) + status = LoginStatus.SSO_TOKEN_MISMATCH + } catch (e: NextcloudHttpRequestFailedException) { + Log.e(TAG, "Catch NC REQ failed", e) + status = LoginStatus.REQ_FAILED + errorMessages.add(getErrorMessageFromException(e)) + } + if (BillsListViewActivity.DEBUG) Log.i(TAG, "FINISHED edit remote project") + return status + } + + private fun onPostExecute(status: LoginStatus) { + var errorString = "" + if (status != LoginStatus.OK) { + errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n" + for (errorMessage in errorMessages) { + errorString += "$errorMessage\n" + } + errorString += "\n" + for (e in exceptions) { + errorString += "${e.javaClass.name}: ${e.message}" + } + if (status == LoginStatus.SSO_TOKEN_MISMATCH) { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_SSO_TOKEN_MISMATCH)) + } + } else { + dbHelper.updateProject( + project!!.id, newName, newEmail, newPassword, + null, null, null, null, null, null + ) + } + callback.onFinish(newName ?: "", errorString) + } + } + + fun deleteRemoteProject(projId: Long, callback: ICallback): Boolean { + updateNetworkStatus() + if (isSyncPossible) { + DeleteRemoteProjectTask(projId, callback).execute() + return true + } + return false + } + + private inner class DeleteRemoteProjectTask(projId: Long, private val callback: ICallback) { + private val project: DBProject? = dbHelper.getProject(projId) + private val exceptions: MutableList = ArrayList() + private val errorMessages: MutableList = ArrayList() + + fun execute(): DeleteRemoteProjectTask { + scope.launch { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } + return this + } + + private fun doWork(): LoginStatus { + val nextcloudClient = createNextcloudClient() + var version: String? = null + if (nextcloudClient != null) { + try { + val response = nextcloudClient.getCapabilities(null) + version = response.cospendVersion + } catch (e: Exception) { + Log.i(TAG, "Failed to get cospend version when syncing: $e") + } + } + val client = createVersatileProjectSyncClient(version) + if (BillsListViewActivity.DEBUG) Log.i(TAG, "STARTING delete remote project") + var status = LoginStatus.OK + try { + val response = client!!.deleteRemoteProject(project!!) + if (BillsListViewActivity.DEBUG) Log.i(TAG, "RESPONSE delete remote project : ${response.stringContent}") + } catch (e: IOException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "Exception", e) + exceptions.add(e) + status = LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + Log.e(TAG, "Catch JSONException", e) + status = LoginStatus.JSON_FAILED + } catch (e: TokenMismatchException) { + Log.e(TAG, "Catch MISMATCHTOKEN", e) + status = LoginStatus.SSO_TOKEN_MISMATCH + } catch (e: NextcloudHttpRequestFailedException) { + Log.e(TAG, "Catch NC REQ failed", e) + status = LoginStatus.REQ_FAILED + errorMessages.add(getErrorMessageFromException(e)) + } + if (BillsListViewActivity.DEBUG) Log.i(TAG, "FINISHED delete device") + return status + } + + private fun onPostExecute(status: LoginStatus) { + var errorString = "" + if (status != LoginStatus.OK) { + errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n" + for (errorMessage in errorMessages) { + errorString += "$errorMessage\n" + } + errorString += "\n" + for (e in exceptions) { + errorString += "${e.javaClass.name}: ${e.message}" + } + if (status == LoginStatus.SSO_TOKEN_MISMATCH) { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_SSO_TOKEN_MISMATCH)) + } + } else { + dbHelper.deleteProject(project!!.id) + } + callback.onFinish(project?.id?.toString() ?: "", errorString) + } + } + + fun createRemoteProject(remoteId: String, name: String, email: String, password: String, ihmUrl: String, projectType: ProjectType, callback: IProjectCreationCallback): Boolean { + if (isSyncPossible) { + val proj = DBProject( + 0, remoteId, + if (projectType == ProjectType.COSPEND) "" else password, + name, ihmUrl, email, + null, projectType, 0L, null, + false, DBProject.ACCESS_LEVEL_UNKNOWN, null + ) + CreateRemoteProjectTask(proj, callback).execute() + return true + } + return false + } + + private inner class CreateRemoteProjectTask(private val project: DBProject, private val callback: IProjectCreationCallback) { + private val exceptions: MutableList = ArrayList() + private val errorMessages: MutableList = ArrayList() + private var usePrivateApi = false + + fun execute(): CreateRemoteProjectTask { + scope.launch { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } + return this + } + + private fun doWork(): LoginStatus { + val nextcloudClient = createNextcloudClient() + var version: String? = null + if (nextcloudClient != null) { + try { + val response = nextcloudClient.getCapabilities(null) + version = response.cospendVersion + } catch (e: Exception) { + Log.i(TAG, "Failed to get cospend version when syncing: $e") + } + } + val client = createVersatileProjectSyncClient(version) + if (BillsListViewActivity.DEBUG) Log.i(TAG, "STARTING create remote project") + var status = LoginStatus.OK + try { + val response: ServerResponse.CreateRemoteProjectResponse + if (canCreateAuthenticatedProject(project)) { + response = client!!.createAuthenticatedRemoteProject(project) + usePrivateApi = true + } else { + response = client!!.createAnonymousRemoteProject(project) + } + if (BillsListViewActivity.DEBUG) Log.i(TAG, "RESPONSE create remote project : ${response.stringContent}") + } catch (e: IOException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "Exception", e) + exceptions.add(e) + status = LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "JSON Exception", e) + exceptions.add(e) + status = LoginStatus.JSON_FAILED + } catch (e: TokenMismatchException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "Exception", e) + exceptions.add(e) + status = LoginStatus.CONNECTION_FAILED + } catch (e: NextcloudHttpRequestFailedException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "Exception", e) + exceptions.add(e) + status = LoginStatus.REQ_FAILED + errorMessages.add(getErrorMessageFromException(e)) + } + if (BillsListViewActivity.DEBUG) Log.i(TAG, "FINISHED create remote project") + return status + } + + private fun onPostExecute(status: LoginStatus) { + var errorString = "" + if (status != LoginStatus.OK) { + errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n" + for (errorMessage in errorMessages) { + errorString += "$errorMessage\n" + } + errorString += "\n" + for (e in exceptions) { + errorString += "${e.javaClass.name}: ${e.message}" + } + } + callback.onFinish(project.remoteId, errorString, usePrivateApi) + } + } + + private fun hasChanged(localBill: DBBill, remoteBill: DBBill): Boolean { + if (localBill.payerId == remoteBill.payerId && + localBill.amount == remoteBill.amount && + localBill.timestamp == remoteBill.timestamp && + localBill.what == remoteBill.what && + localBill.comment == remoteBill.comment && + localBill.paymentMode == remoteBill.paymentMode && + localBill.paymentModeRemoteId == remoteBill.paymentModeRemoteId && + localBill.categoryRemoteId == remoteBill.categoryRemoteId + ) { + val localRepeat = localBill.repeat ?: DBBill.NON_REPEATED + val remoteRepeat = remoteBill.repeat ?: DBBill.NON_REPEATED + return localRepeat != remoteRepeat + } + return true + } + + fun updateMemberAvatar(memberId: Long) { + updateNetworkStatus() + if (isNextcloudAccountConfigured(appContext) && isSyncPossible) { + UpdateMemberAvatarTask(memberId).execute() + } + } + + fun runAccountProjectsSync() { + Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...") + updateNetworkStatus() + if (isNextcloudAccountConfigured(appContext) && isSyncPossible && !syncAccountProjectsActive) { + SyncAccountProjectsTask().execute() + if (preferences.getBoolean(appContext.getString(R.string.pref_key_use_server_color), true)) { + GetNCColorTask().execute() + } + GetNCUserAvatarTask().execute() + } + } + + private val apiCallback = object : NextcloudAPI.ApiConnectedListener { + override fun onConnected() { + Log.d(TAG, "API connected!!!!") + } + + override fun onError(ex: Exception) {} + } + + private fun createNextcloudClient(): NextcloudClient? { + val useSSO = preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false) + return if (useSSO) { + try { + val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(appContext) + val nextcloudAPI = NextcloudAPI(appContext, ssoAccount, GsonBuilder().create(), apiCallback) + NextcloudClient("", ssoAccount.userId, "", nextcloudAPI, appContext) + } catch (_: Exception) { + null + } + } else { + val url = preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS) ?: "" + val username = preferences.getString(AccountActivity.SETTINGS_USERNAME, AccountActivity.DEFAULT_SETTINGS) ?: "" + val password = preferences.getString(AccountActivity.SETTINGS_PASSWORD, AccountActivity.DEFAULT_SETTINGS) ?: "" + NextcloudClient(url, username, password, null, appContext) + } + } + + private inner class SyncAccountProjectsTask { + private var client: NextcloudClient? = null + private val exceptions: MutableList = ArrayList() + private val errorMessages: MutableList = ArrayList() + + fun execute(): SyncAccountProjectsTask { + scope.launch { + syncAccountProjectsActive = true + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + syncAccountProjectsActive = false + } + return this + } + + private fun doWork(): LoginStatus { + client = createNextcloudClient() + Log.i(TAG, "STARTING account projects SYNCHRONIZATION") + val status = client?.let { pullRemoteProjects(it) } ?: LoginStatus.SSO_TOKEN_MISMATCH + Log.i(TAG, "SYNCHRONIZATION FINISHED") + return status + } + + private fun pullRemoteProjects(client: NextcloudClient): LoginStatus { + Log.d(TAG, "pullRemoteProjects()") + return try { + val url = if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + SingleAccountHelper.getCurrentSingleSignOnAccount(appContext).url + } else { + preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS) ?: "" + } + + val localProjects = dbHelper.projects + val capabilitiesResponse = client.getCapabilities(null) + val cospendVersion = capabilitiesResponse.cospendVersion + val useOcsApi = cospendVersion != null && SupportUtil.compareVersions(cospendVersion, "1.6.1") >= 0 + + val response = client.getAccountProjects(useOcsApi) + val remoteAccountProjects = response.getAccountProjects(url) + dbHelper.clearAccountProjects() + for (remoteAccountProject in remoteAccountProjects) { + dbHelper.addAccountProject(remoteAccountProject) + Log.v(TAG, "received account project $remoteAccountProject") + val existingProj = localProjects.find { + it.remoteId == remoteAccountProject.remoteId && + it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend" + } + if (existingProj == null) { + val newProj = DBProject(0, + remoteAccountProject.remoteId, + "", + remoteAccountProject.name, + remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend", + "", + null, + ProjectType.COSPEND, + 0L, + null, + false, + DBProject.ACCESS_LEVEL_UNKNOWN, + null, + remoteAccountProject.archivedTs + ) + dbHelper.addProject(newProj) + } else if (existingProj.archivedTs != remoteAccountProject.archivedTs) { + dbHelper.updateProject( + projId = existingProj.id, + newName = null, + newEmail = null, + newPassword = null, + newLastPayerId = null, + newLastSyncedTimestamp = null, + newCurrencyName = null, + newDeletionDisabled = null, + newMyAccessLevel = null, + newBearerToken = null, + newArchivedTs = remoteAccountProject.archivedTs ?: 0L + ) + } + } + LoginStatus.OK + } catch (_: ServerResponse.NotModifiedException) { + Log.d(TAG, "No changes, nothing to do.") + LoginStatus.OK + } catch (e: IOException) { + Log.e(TAG, "Exception", e) + exceptions.add(e) + LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + Log.e(TAG, "Exception", e) + exceptions.add(e) + LoginStatus.JSON_FAILED + } catch (e: TokenMismatchException) { + Log.e(TAG, "Catch MISMATCHTOKEN", e) + LoginStatus.SSO_TOKEN_MISMATCH + } catch (e: NextcloudHttpRequestFailedException) { + Log.e(TAG, "Catch REQ FAILED", e) + errorMessages.add(getErrorMessageFromException(e)) + LoginStatus.REQ_FAILED + } + } + + private fun onPostExecute(status: LoginStatus) { + if (status != LoginStatus.OK) { + var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n" + for (errorMessage in errorMessages) { + errorString += "$errorMessage\n" + } + errorString += "\n" + for (e in exceptions) { + errorString += "${e.javaClass.name}: ${e.message}" + } + val intent = Intent(MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNC_FAILED) + intent.putExtra(MainConstants.BROADCAST_ERROR_MESSAGE, errorString) + appContext.sendBroadcast(intent) + if (status == LoginStatus.SSO_TOKEN_MISMATCH) { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_SSO_TOKEN_MISMATCH)) + } + } else { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNCED)) + } + } + } + + private inner class GetNCColorTask { + fun execute(): GetNCColorTask { + scope.launch { + withContext(Dispatchers.IO) { + doWork() + } + } + return this + } + + private fun doWork(): LoginStatus { + val client = createNextcloudClient() + Log.i(TAG, "STARTING get color") + return if (client != null) { + getNextcloudColor(client) + } else { + LoginStatus.SSO_TOKEN_MISMATCH + } + } + + private fun getNextcloudColor(client: NextcloudClient): LoginStatus { + Log.d(TAG, "getNextcloudColor()") + return try { + val response = client.getCapabilities(null) + var color = response.color + + if (!color.isNullOrEmpty() && color.startsWith("#")) { + if (color.length == 4) { + color = "#" + color[1] + color[1] + color[2] + color[2] + color[3] + color[3] + } + val intColor = color.toColorInt() + Log.d(TAG, "COLOR from server is $color") + preferences.edit { + putInt( + appContext.getString(R.string.pref_key_server_color), + intColor + ) + } + } + LoginStatus.OK + } catch (e: Exception) { + Log.e(TAG, "Exception in get color", e) + LoginStatus.CONNECTION_FAILED + } + } + } + + private inner class GetNCUserAvatarTask { + fun execute(): GetNCUserAvatarTask { + scope.launch { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } + return this + } + + private fun doWork(): LoginStatus { + val client = createNextcloudClient() + Log.i(TAG, "STARTING get account avatar") + return if (client != null) { + getNextcloudUserAvatar(client) + } else { + LoginStatus.SSO_TOKEN_MISMATCH + } + } + + private fun getNextcloudUserAvatar(client: NextcloudClient): LoginStatus { + Log.d(TAG, "getNextcloudUserAvatar()") + return try { + val response = client.getAvatar(null) + val avatar = response.avatarString + + if (avatar.isNotEmpty()) { + preferences.edit { + putString( + appContext.getString(R.string.pref_key_avatar), + avatar + ) + } + } + LoginStatus.OK + } catch (e: Exception) { + Log.e(TAG, "Exception in get avatar", e) + LoginStatus.CONNECTION_FAILED + } + } + + private fun onPostExecute(status: LoginStatus) { + if (status == LoginStatus.OK) { + appContext.sendBroadcast(Intent(MainConstants.BROADCAST_AVATAR_UPDATED)) + } + } + } + + private inner class UpdateMemberAvatarTask(private val memberId: Long) { + fun execute(): UpdateMemberAvatarTask { + scope.launch { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } + return this + } + + private fun doWork(): LoginStatus { + val client = createNextcloudClient() + Log.i(TAG, "STARTING get avatar for member") + return if (client != null) { + getNextcloudUserAvatar(client) + } else { + LoginStatus.SSO_TOKEN_MISMATCH + } + } + + private fun getNextcloudUserAvatar(client: NextcloudClient): LoginStatus { + Log.d(TAG, "getNextcloudUserAvatar() $memberId") + return try { + val m = dbHelper.getMember(memberId) + val targetUserName = m?.ncUserId + if (!targetUserName.isNullOrEmpty()) { + val response = client.getAvatar(targetUserName) + val avatar = response.avatarString + + if (avatar.isNotEmpty()) { + dbHelper.updateMember( + memberId, null, null, null, + null, null, null, null, null, + null, avatar + ) + Log.d(TAG, "RECEIVED AVATAR for member $memberId length ${avatar.length}") + } + } + LoginStatus.OK + } catch (e: Exception) { + Log.e(TAG, "Exception in get member avatar", e) + LoginStatus.CONNECTION_FAILED + } + } + + private fun onPostExecute(status: LoginStatus) { + if (status == LoginStatus.OK) { + val intent = Intent(MainConstants.BROADCAST_AVATAR_UPDATED) + intent.putExtra(MainConstants.BROADCAST_AVATAR_UPDATED_MEMBER, memberId) + appContext.sendBroadcast(intent) + } + } + } + + fun getRemoteProjectInfo(project: DBProject, callback: ICallback): Boolean { + if (isSyncPossible) { + GetRemoteProjectInfoTask(project, callback).execute() + return true + } + return false + } + + private inner class GetRemoteProjectInfoTask(private val project: DBProject, private val callback: ICallback) { + private val exceptions: MutableList = ArrayList() + private val errorMessages: MutableList = ArrayList() + + fun execute(): GetRemoteProjectInfoTask { + scope.launch { + val status = withContext(Dispatchers.IO) { + doWork() + } + onPostExecute(status) + } + return this + } + + private fun doWork(): LoginStatus { + var version: String? = null + if (project.type == ProjectType.COSPEND) { + val nextcloudClient = createNextcloudClient() + if (nextcloudClient != null) { + try { + val response = nextcloudClient.getCapabilities(project) + version = response.cospendVersion + } catch (e: Exception) { + Log.i(TAG, "Failed to get cospend version when syncing: $e") + } + } + } + val client = createVersatileProjectSyncClient(version) + if (BillsListViewActivity.DEBUG) Log.i(TAG, "STARTING create remote project") + return try { + val projResponse = client!!.getProject(project, 0, null) + val name = projResponse.name + val email = projResponse.email + Log.e(TAG, "Project info: $name and $email") + LoginStatus.OK + } catch (e: NextcloudHttpRequestFailedException) { + if (BillsListViewActivity.DEBUG) Log.e(TAG, "Exception1", e) + errorMessages.add(getErrorMessageFromException(e)) + LoginStatus.REQ_FAILED + } catch (e: Exception) { + Log.e(TAG, "Failed to get project info: $e") + exceptions.add(e) + LoginStatus.CONNECTION_FAILED + } + } + + private fun onPostExecute(status: LoginStatus) { + var errorString = "" + if (status != LoginStatus.OK) { + for (errorMessage in errorMessages) { + errorString += "$errorMessage\n" + } + errorString += "\n" + for (e in exceptions) { + errorString += "${e.javaClass.name}: ${e.message}" + } + } + callback.onFinish("", errorString) + } + } + + companion object { + private val TAG = CowspentServerSyncHelper::class.java.simpleName + + private var instance: CowspentServerSyncHelper? = null + private val projectIdsToSync: MutableList = ArrayList() + + @Synchronized + fun getInstance(dbHelper: CowspentSQLiteOpenHelper): CowspentServerSyncHelper { + if (instance == null) { + instance = CowspentServerSyncHelper(dbHelper) + } + return instance!! + } + + fun isNextcloudAccountConfigured(context: Context): Boolean { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() || + preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false) + } + + fun getNextcloudAccountServerUrl(context: Context): String { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { + try { + val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(context.applicationContext) + ssoAccount.url.replace("/+$".toRegex(), "") + } catch (_: Exception) { + "" + } + } else { + preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS)?.replace("/+$".toRegex(), "") ?: "" + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt new file mode 100644 index 0000000..dfdcb6c --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt @@ -0,0 +1,119 @@ +package net.helcel.cowspent.theme + +import android.annotation.SuppressLint +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.preference.PreferenceManager +import net.helcel.cowspent.util.ColorUtils + +object ThemeUtils { + + val Shapes = Shapes( + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(24.dp), + large = RoundedCornerShape(28.dp) + ) + + @SuppressLint("ConflictingOnColor") + @Composable + fun CowspentTheme( + accentColor: Int? = null, + darkTheme: Boolean? = null, + content: @Composable () -> Unit + ) { + val context = LocalContext.current + val config = LocalConfiguration.current + val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } + val nightModeKey = stringResource(net.helcel.cowspent.R.string.pref_key_night_mode) + + val resolvedDarkTheme = darkTheme ?: run { + val nightMode = sharedPreferences.getString(nightModeKey, "-1") ?: "-1" + when (nightMode) { + "1" -> false + "2" -> true + else -> isSystemInDarkTheme() + } + } + + val resolvedAccentColor = accentColor ?: remember(resolvedDarkTheme) { + ColorUtils.primaryColor(context, resolvedDarkTheme) + } + + val themedContext = remember(resolvedDarkTheme) { + + if (resolvedDarkTheme != (config.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)) { + config.uiMode = (config.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (resolvedDarkTheme) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + context.createConfigurationContext(config) + } else { + context + } + } + + val onPrimary = if (ColorUtils.isLightColor(resolvedAccentColor)) Color.Black else Color.White + + val colors = if (resolvedDarkTheme) { + darkColors( + primary = Color(resolvedAccentColor), + primaryVariant = Color(resolvedAccentColor), + onPrimary = onPrimary, + secondary = Color(resolvedAccentColor), + onSecondary = onPrimary, + background = Color(0xFF121212), + surface = Color(0xFF121212), + onBackground = Color.White, + onSurface = Color.White + ) + } else { + lightColors( + primary = Color(resolvedAccentColor), + primaryVariant = Color(resolvedAccentColor), + onPrimary = onPrimary, + secondary = Color(resolvedAccentColor), + onSecondary = onPrimary, + background = Color.White, + surface = Color.White, + onBackground = Color.Black, + onSurface = Color.Black + ) + } + + MaterialTheme( + colors = colors, + shapes = Shapes, + content = { + CompositionLocalProvider(LocalContext provides themedContext) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colors.background + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.primary) + .statusBarsPadding() + ) + Box(modifier = Modifier.fillMaxSize()) { + content() + } + } + } + } + } + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt b/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt new file mode 100644 index 0000000..3af97c3 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt @@ -0,0 +1,58 @@ +package net.helcel.cowspent.util + +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBPaymentMode + +object BillFormatter { + fun formatBills( + bills: List, + membersMap: Map, + categoriesMap: Map, + paymentModesMap: Map + ) { + for (bill in bills) { + var whatPrefix = "" + val pm = paymentModesMap[bill.paymentModeRemoteId.toLong()] + if (pm != null) { + whatPrefix += pm.icon + " " + } else { + when (bill.paymentModeRemoteId) { + DBBill.PAYMODE_ID_CARD -> whatPrefix += "\uD83D\uDCB3 " + DBBill.PAYMODE_ID_CASH -> whatPrefix += "\uD83D\uDCB5 " + DBBill.PAYMODE_ID_CHECK -> whatPrefix += "\uD83C\uDFAB " + DBBill.PAYMODE_ID_TRANSFER -> whatPrefix += "⇄ " + DBBill.PAYMODE_ID_ONLINE_SERVICE -> whatPrefix += "\uD83C\uDF0E " + } + } + + val cat = categoriesMap[bill.categoryRemoteId.toLong()] + if (cat != null) { + whatPrefix += cat.icon + " " + } else { + when (bill.categoryRemoteId) { + DBBill.CATEGORY_GROCERIES -> whatPrefix += "\uD83D\uDED2 " + DBBill.CATEGORY_LEISURE -> whatPrefix += "\uD83C\uDF89 " + DBBill.CATEGORY_RENT -> whatPrefix += "\uD83C\uDFE0 " + DBBill.CATEGORY_BILLS -> whatPrefix += "\uD83C\uDF29 " + DBBill.CATEGORY_CULTURE -> whatPrefix += "\uD83D\uDEB8 " + DBBill.CATEGORY_HEALTH -> whatPrefix += "\uD83D\uDC9A " + DBBill.CATEGORY_SHOPPING -> whatPrefix += "\uD83D\uDECD " + DBBill.CATEGORY_REIMBURSEMENT -> whatPrefix += "\uD83D\uDCB0 " + DBBill.CATEGORY_RESTAURANT -> whatPrefix += "\uD83C\uDF74 " + DBBill.CATEGORY_ACCOMMODATION -> whatPrefix += "\uD83D\uDECC " + DBBill.CATEGORY_TRANSPORT -> whatPrefix += "\uD83D\uDE8C " + DBBill.CATEGORY_SPORT -> whatPrefix += "\uD83C\uDFBE " + } + } + bill.formattedWhat = whatPrefix + bill.what + + val payerName = membersMap[bill.payerId]?.name ?: bill.payerId.toString() + val owersNames = bill.billOwersIds.joinToString(", ") { id -> + membersMap[id]?.name ?: id.toString() + } + bill.formattedSubtitle = "$payerName \u2192 $owersNames" + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/BillParser.kt b/app/src/main/java/net/helcel/cowspent/util/BillParser.kt new file mode 100644 index 0000000..f403b78 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/BillParser.kt @@ -0,0 +1,68 @@ +package net.helcel.cowspent.util + +import net.helcel.cowspent.model.parsed.AustrianBillQrCode +import net.helcel.cowspent.model.parsed.CroatianBillQrCode +import java.text.ParseException +import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.round +import androidx.core.net.toUri + +object BillParser { + private val austrianQrCodeDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT) + private val croatianQrCodeDateFormat = DateTimeFormatter.ofPattern("yyyyMMdd_HHmm") + + @Throws(ParseException::class) + fun parseAustrianBillFromQrCode(scannedBill: String): AustrianBillQrCode { + val splitBill = scannedBill.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + + if (splitBill.size < 10) { + throw ParseException("Could not parse bill to Austrian format!", 0) + } + + val date = austrianQrCodeDateFormat.parse(splitBill[4]) ?: throw ParseException("Could not parse date", 0) + var totalAmount = 0.0 + for (i in 1..5) { + totalAmount += SupportUtil.commaNumberFormat.parse(splitBill[4 + i])?.toDouble() ?: 0.0 + } + // some amounts may be negative that's why we have to round here + return AustrianBillQrCode(splitBill[2], date, round(totalAmount * 100.0) / 100.0) + } + + @Throws(ParseException::class) + fun parseCroatianBillFromQrCode(scannedBill: String): CroatianBillQrCode { + val uri = scannedBill.toUri() + + // Be defensive, and only allow the known host + if (uri.host == null || uri.host != "porezna.gov.hr") { + throw ParseException("Does not look like a Croatian QR code", 0) + } + + val dates = uri.getQueryParameters("datv") + val amounts = uri.getQueryParameters("izn") + + var date: LocalDateTime? = null + if (!dates.isEmpty()) { + date = LocalDateTime.parse(dates[0], croatianQrCodeDateFormat) + } + + var amount: Double? = null + if (!amounts.isEmpty()) { + try { + amount = SupportUtil.commaNumberFormat.parse(amounts[0])?.toDouble() + } catch (_: NullPointerException) { + // failed to parse as double + } + } + + if (date == null && amount == null) { + throw ParseException("Could not parse bill to Croatian format!", 0) + } + return CroatianBillQrCode( + date, + amount ?: 0.0 + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt new file mode 100644 index 0000000..7711be2 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt @@ -0,0 +1,37 @@ +package net.helcel.cowspent.util + +import android.content.Context +import net.helcel.cowspent.R +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBPaymentMode + +object CategoryUtils { + + fun getDefaultCategories(context: Context, projectId: Long): List { + return listOf( + DBCategory(0, DBBill.CATEGORY_GROCERIES.toLong(), projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"), + DBCategory(0, DBBill.CATEGORY_LEISURE.toLong(), projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"), + DBCategory(0, DBBill.CATEGORY_RENT.toLong(), projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"), + DBCategory(0, DBBill.CATEGORY_BILLS.toLong(), projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"), + DBCategory(0, DBBill.CATEGORY_CULTURE.toLong(), projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"), + DBCategory(0, DBBill.CATEGORY_HEALTH.toLong(), projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"), + DBCategory(0, DBBill.CATEGORY_SHOPPING.toLong(), projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"), + DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT.toLong(), projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"), + DBCategory(0, DBBill.CATEGORY_RESTAURANT.toLong(), projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"), + DBCategory(0, DBBill.CATEGORY_ACCOMMODATION.toLong(), projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"), + DBCategory(0, DBBill.CATEGORY_TRANSPORT.toLong(), projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"), + DBCategory(0, DBBill.CATEGORY_SPORT.toLong(), projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177") + ) + } + + fun getDefaultPaymentModes(context: Context, projectId: Long): List { + return listOf( + DBPaymentMode(0, DBBill.PAYMODE_ID_CARD.toLong(), projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"), + DBPaymentMode(0, DBBill.PAYMODE_ID_CASH.toLong(), projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"), + DBPaymentMode(0, DBBill.PAYMODE_ID_CHECK.toLong(), projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"), + DBPaymentMode(0, DBBill.PAYMODE_ID_TRANSFER.toLong(), projectId, context.getString(R.string.payment_mode_transfer), "⇄", "#00ced1"), + DBPaymentMode(0, DBBill.PAYMODE_ID_ONLINE_SERVICE.toLong(), projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc") + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/ColorUtils.kt b/app/src/main/java/net/helcel/cowspent/util/ColorUtils.kt new file mode 100644 index 0000000..0d965ef --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/ColorUtils.kt @@ -0,0 +1,95 @@ +package net.helcel.cowspent.util + +import android.content.Context +import android.content.res.Configuration +import android.graphics.* +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.google.zxing.WriterException +import net.helcel.cowspent.R +import androidx.core.graphics.createBitmap + +object ColorUtils { + + fun primaryColor(context: Context, isDark: Boolean? = null): Int { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + val resolvedIsDark = isDark ?: ((context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) + + // Determine color mode, migrating from old boolean flags if necessary + val modeKey = context.getString(R.string.pref_key_color_mode) + val colorMode = if (prefs.contains(modeKey)) { + prefs.getString(modeKey, "system") + } else { + val useServer = prefs.getBoolean(context.getString(R.string.pref_key_use_server_color), true) + val useSystem = prefs.getBoolean(context.getString(R.string.pref_key_use_system_color), true) + when { + useServer -> "server" + useSystem -> "system" + else -> "manual" + } + } + + if (colorMode == "server") { + val serverColor = prefs.getInt(context.getString(R.string.pref_key_server_color), -1) + if (serverColor != -1) { + return serverColor + } + } + + if (colorMode == "system" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return if (resolvedIsDark) { + context.getColor(android.R.color.system_accent1_200) + } else { + context.getColor(android.R.color.system_accent1_600) + } + } + + val themedContext = if (isDark != null) { + val config = Configuration(context.resources.configuration) + config.uiMode = (config.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (isDark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + context.createConfigurationContext(config) + } else { + context + } + + return prefs.getInt( + context.getString(R.string.pref_key_color), + ContextCompat.getColor(themedContext, R.color.primary) + ) + } + + fun isLightColor(color: Int): Boolean { + return androidx.core.graphics.ColorUtils.calculateLuminance(color) > 0.5 + } + + @Throws(WriterException::class) + fun encodeAsBitmap(str: String): Bitmap? { + val result = try { + MultiFormatWriter().encode( + str, + BarcodeFormat.QR_CODE, 400, 400, null + ) + } catch (_: IllegalArgumentException) { + // Unsupported format + return null + } + val w = result.width + val h = result.height + val pixels = IntArray(w * h) + for (y in 0 until h) { + val offset = y * w + for (x in 0 until w) { + pixels[offset + x] = if (result[x, y]) Color.BLACK else Color.WHITE + } + } + val bitmap = createBitmap(w, h) + bitmap.setPixels(pixels, 0, 400, 0, 0, w, h) + return bitmap + } + +} diff --git a/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt b/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt new file mode 100644 index 0000000..dabbdd0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt @@ -0,0 +1,121 @@ +package net.helcel.cowspent.util + +import android.util.Base64 +import android.util.Log +import androidx.annotation.StringRes +import net.helcel.cowspent.R +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.net.MalformedURLException +import java.net.SocketTimeoutException + +object CospendClientUtil { + + enum class LoginStatus(@param:StringRes val str: Int) { + OK(0), + AUTH_FAILED(R.string.error_username_password_invalid), + CONNECTION_FAILED(R.string.error_io), + NO_NETWORK(R.string.error_no_network), + JSON_FAILED(R.string.error_json), + SERVER_FAILED(R.string.error_server), + SSO_TOKEN_MISMATCH(R.string.error_token_mismatch), + REQ_FAILED(R.string.error_req_failed) + } + + fun isHttp(url: String?): Boolean { + return url != null && url.length > 4 && url.startsWith("http") && url[4] != 's' + } + + fun formatURL(urlParam: String): String { + var url = urlParam + if (!url.endsWith("/")) { + url += "/" + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://$url" + } + val replacements = arrayOf("v0.2/", "api/", "apps/", "index.php/") + for (replacement in replacements) { + if (url.endsWith(replacement)) { + url = url.substring(0, url.length - replacement.length) + } + } + return url + } + + fun isValidLogin(url: String, username: String, password: String): LoginStatus { + return try { + val targetURL = url + "index.php/apps/cospend/api/ping" + val con = SupportUtil.getHttpURLConnection(targetURL) + con.requestMethod = "GET" + con.setRequestProperty( + "Authorization", + "Basic " + + String( + Base64.encode( + ("$username:$password").toByteArray(), + Base64.NO_WRAP + ) + ) + ) + con.connectTimeout = 10 * 1000 // 10 seconds + con.connect() + + Log.v(CospendClientUtil::class.java.simpleName, "Establishing connection to server") + when (con.responseCode) { + 200 -> { + Log.v(CospendClientUtil::class.java.simpleName, "" + con.responseMessage) + val result = StringBuilder() + val rd = BufferedReader(InputStreamReader(con.inputStream)) + var line: String? + while ((rd.readLine().also { line = it }) != null) { + result.append(line) + } + Log.v(CospendClientUtil::class.java.simpleName, result.toString()) + JSONArray(result.toString()) + LoginStatus.OK + } + in 401..403 -> { + LoginStatus.AUTH_FAILED + } + else -> { + LoginStatus.SERVER_FAILED + } + } + } catch (e: MalformedURLException) { + Log.e(CospendClientUtil::class.java.simpleName, "Exception", e) + LoginStatus.CONNECTION_FAILED + } catch (e: SocketTimeoutException) { + Log.e(CospendClientUtil::class.java.simpleName, "Exception", e) + LoginStatus.CONNECTION_FAILED + } catch (e: IOException) { + Log.e(CospendClientUtil::class.java.simpleName, "Exception", e) + LoginStatus.CONNECTION_FAILED + } catch (e: JSONException) { + Log.e(CospendClientUtil::class.java.simpleName, "Exception", e) + LoginStatus.JSON_FAILED + } + } + + fun isValidURL(url: String): Boolean { + val result = StringBuilder() + return try { + val con = SupportUtil.getHttpURLConnection( url + "status.php") + con.requestMethod = VersatileProjectSyncClient.METHOD_GET + con.connectTimeout = 10 * 1000 // 10 seconds + val rd = BufferedReader(InputStreamReader(con.inputStream)) + var line: String? + while ((rd.readLine().also { line = it }) != null) { + result.append(line) + } + val response = JSONObject(result.toString()) + response.getBoolean("installed") + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/Cowspent.kt b/app/src/main/java/net/helcel/cowspent/util/Cowspent.kt new file mode 100644 index 0000000..833ce5a --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/Cowspent.kt @@ -0,0 +1,31 @@ +package net.helcel.cowspent.util + +import android.app.Application +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.PreferenceManager +import net.helcel.cowspent.R + +class Cowspent : Application() { + + override fun onCreate() { + setAppTheme(getAppTheme(applicationContext)) + super.onCreate() + } + + companion object { + + fun setAppTheme(mode: Int) { + AppCompatDelegate.setDefaultNightMode(mode) + } + + fun getAppTheme(context: Context): Int { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val strValue = prefs.getString( + context.getString(R.string.pref_key_night_mode), + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM.toString() + ) + return strValue?.toInt() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt new file mode 100644 index 0000000..c3bfec4 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt @@ -0,0 +1,88 @@ +package net.helcel.cowspent.util + +import net.helcel.cowspent.model.* +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper + +object ExportUtil { + + @JvmStatic + fun createExportContent(db: CowspentSQLiteOpenHelper, projectId: Long): String { + var fileContent = "" + + // get information + val project = db.getProject(projectId) ?: return "" + val membersById: MutableMap = HashMap() + val members = db.getMembersOfProject(projectId, null) + for (m in members) { + membersById[m.id] = m + } + val bills = db.getBillsOfProject(projectId).toMutableList() + + // write header + fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode\n" + + // write members + for (m in members) { + val fakeBill = DBBill( + 0, 0, projectId, m.id, 1.0, 666, + "deleteMeIfYouWant", DBBill.STATE_OK, DBBill.NON_REPEATED, + DBBill.PAYMODE_NONE, 0, "", 0 + ) + val fakeBillOwers: MutableList = ArrayList() + fakeBillOwers.add(DBBillOwer(0, 0, m.id)) + fakeBill.billOwers = fakeBillOwers + bills.add(0, fakeBill) + } + + // write bills + for (b in bills) { + val payerId = b.payerId + val payer = membersById[payerId] ?: continue + val payerName = payer.name + val payerWeight = payer.weight + val payerActive = if (payer.isActivated) 1 else 0 + val billOwers = b.billOwers + var owersTxt = "" + for (bo in billOwers) { + owersTxt += membersById[bo.memberId]?.name + "," + } + owersTxt = owersTxt.replace(",$".toRegex(), "") + fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," + + "$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryRemoteId}," + + "${b.paymentMode}\n" + } + + // write categories + val cats = db.getCategories(projectId) + if (cats.isNotEmpty()) { + fileContent += "\ncategoryname,categoryid,icon,color\n" + for (cat in cats) { + fileContent += "\"${cat.name}\",${cat.id},\"${cat.icon}\",\"${cat.color}\"\n" + } + } + + // write currencies + val curs = db.getCurrencies(projectId) + if (curs.isNotEmpty() && project.currencyName != null && + project.currencyName!!.isNotEmpty() && project.currencyName != "null" + ) { + fileContent += "\ncurrencyname,exchange_rate\n" + fileContent += "\"${project.currencyName}\",1\n" + for (cur in curs) { + fileContent += "\"${cur.name}\",${cur.exchangeRate}\n" + } + } + + return fileContent + } + + @JvmStatic + fun createExportFileName(db: CowspentSQLiteOpenHelper, projectId: Long): String { + val project = db.getProject(projectId) ?: return "export.csv" + return if (project.name.isEmpty()) { + project.remoteId + ".csv" + } else { + project.name + ".csv" + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/ICallback.kt b/app/src/main/java/net/helcel/cowspent/util/ICallback.kt new file mode 100644 index 0000000..6d4453f --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/ICallback.kt @@ -0,0 +1,10 @@ +package net.helcel.cowspent.util + +/** + * Callback + */ +interface ICallback { + fun onFinish() + fun onFinish(result: String, message: String) + fun onScheduled() +} diff --git a/app/src/main/java/net/helcel/cowspent/util/IProjectCreationCallback.kt b/app/src/main/java/net/helcel/cowspent/util/IProjectCreationCallback.kt new file mode 100644 index 0000000..b70f9c9 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/IProjectCreationCallback.kt @@ -0,0 +1,9 @@ +package net.helcel.cowspent.util + +/** + * Callback + */ +interface IProjectCreationCallback { + fun onFinish(result: String, message: String, usePrivateApi: Boolean) + +} diff --git a/app/src/main/java/net/helcel/cowspent/util/IRefreshBillsListCallback.kt b/app/src/main/java/net/helcel/cowspent/util/IRefreshBillsListCallback.kt new file mode 100644 index 0000000..cbbd216 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/IRefreshBillsListCallback.kt @@ -0,0 +1,8 @@ +package net.helcel.cowspent.util + +/** + * Call back into the BillsListActivity and ask it to refresh the list in the UI + */ +interface IRefreshBillsListCallback { + fun refreshLists(scrollToTop: Boolean) +} diff --git a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt new file mode 100644 index 0000000..2ac2d64 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt @@ -0,0 +1,302 @@ +package net.helcel.cowspent.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Log +import androidx.annotation.WorkerThread +import com.nextcloud.android.sso.QueryParam +import com.nextcloud.android.sso.aidl.NextcloudRequest +import com.nextcloud.android.sso.api.NextcloudAPI +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException +import com.nextcloud.android.sso.exceptions.TokenMismatchException +import net.helcel.cowspent.model.DBProject +import org.json.JSONException +import org.json.JSONObject +import java.io.* +import java.net.HttpURLConnection + +@WorkerThread +class NextcloudClient( + private val url: String, + private val username: String, + private val password: String, + private val nextcloudAPI: NextcloudAPI?, + private val context: Context +) { + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getAccountProjects(useOcsApi: Boolean): ServerResponse.AccountProjectsResponse { + val target = if (useOcsApi) + "/ocs/v2.php/apps/cospend/api/v1/projects" + else + "/index.php/apps/cospend/getProjects" + val method = if (useOcsApi) METHOD_GET else METHOD_POST + return if (nextcloudAPI != null) { + Log.d(javaClass.simpleName, "using SSO to get/sync account projects") + Log.d(javaClass.simpleName, "Sync projects target $target") + ServerResponse.AccountProjectsResponse( + requestServerWithSSO(nextcloudAPI, target, method, null, useOcsApi), + useOcsApi + ) + } else { + Log.d(javaClass.simpleName, "Sync projects target $target") + ServerResponse.AccountProjectsResponse( + requestServer(target, method, null, "", true, useOcsApi), + useOcsApi + ) + } + } + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getCapabilities(project: DBProject?): ServerResponse.CapabilitiesResponse { + val target: String = if (project == null || url != "") { + "/ocs/v2.php/cloud/capabilities" + } else { + val realServerUrl = project.serverUrl!! + .replace("/apps/cospend", "") + .replace("/index.php", "") + "$realServerUrl/ocs/v2.php/cloud/capabilities" + } + return if (nextcloudAPI != null) { + Log.d(javaClass.simpleName, "using SSO to get color") + ServerResponse.CapabilitiesResponse(requestServerWithSSO(nextcloudAPI, target, METHOD_GET, null, true)) + } else { + ServerResponse.CapabilitiesResponse(requestServer(target, METHOD_GET, null, null, + needLogin = true, + isOCSRequest = true + )) + } + } + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getAvatar(otherUserName: String?): ServerResponse.AvatarResponse { + val targetUserName = otherUserName ?: username + val target = "/index.php/avatar/$targetUserName/45" + return if (nextcloudAPI != null) { + Log.d(javaClass.simpleName, "using SSO to get avatar") + ServerResponse.AvatarResponse(imageRequestServerWithSSO(nextcloudAPI, target, METHOD_GET, null)) + } else { + ServerResponse.AvatarResponse(imageRequestServer(target, METHOD_GET, null, null, + needLogin = true, + isOCSRequest = false + )) + } + } + + @Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + private fun requestServerWithSSO( + nextcloudAPI: NextcloudAPI, + target: String, + method: String, + params: Collection?, + isOCSRequest: Boolean + ): VersatileProjectSyncClient.ResponseData { + val result = StringBuilder() + val headers: MutableMap> = HashMap() + if (isOCSRequest) { + val acceptHeader: MutableList = ArrayList() + acceptHeader.add("application/json") + headers["Accept"] = acceptHeader + } + val nextcloudRequest: NextcloudRequest = if (params == null) { + NextcloudRequest.Builder() + .setMethod(method) + .setUrl(target) + .setHeader(headers) + .build() + } else { + NextcloudRequest.Builder() + .setMethod(method) + .setUrl(target) + .setParameter(params) + .setHeader(headers) + .build() + } + try { + val response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest) + val inputStream = response.body + val rd = BufferedReader(InputStreamReader(inputStream)) + var line: String? + while (rd.readLine().also { line = it } != null) { + result.append(line) + } + Log.d(javaClass.simpleName, "RES $result") + inputStream.close() + } catch (e: TokenMismatchException) { + Log.d(javaClass.simpleName, "Mismatcho SSO server request error $e") + throw e + } catch (e: NextcloudHttpRequestFailedException) { + Log.d(javaClass.simpleName, "SSO server HTTP request failed ${e.statusCode}") + throw e + } catch (e: Exception) { + Log.d(javaClass.simpleName, "SSO server request error $e") + } + return VersatileProjectSyncClient.ResponseData(result.toString(), "", 0) + } + + @Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + private fun imageRequestServerWithSSO( + nextcloudAPI: NextcloudAPI, + target: String, + method: String, + params: Collection? + ): VersatileProjectSyncClient.ResponseData { + var strBase64 = "" + val nextcloudRequest: NextcloudRequest = if (params == null) { + NextcloudRequest.Builder() + .setMethod(method) + .setUrl(target) + .build() + } else { + NextcloudRequest.Builder() + .setMethod(method) + .setUrl(target) + .setParameter(params) + .build() + } + try { + val response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest) + val inputStream = response.body + val selectedImage = BitmapFactory.decodeStream(inputStream) + val stream = ByteArrayOutputStream() + selectedImage.compress(Bitmap.CompressFormat.PNG, 100, stream) + val byteArray = stream.toByteArray() + strBase64 = Base64.encodeToString(byteArray, 0) + inputStream.close() + } catch (e: TokenMismatchException) { + Log.d(javaClass.simpleName, "Mismatcho SSO server request error $e") + throw e + } catch (e: NextcloudHttpRequestFailedException) { + Log.d(javaClass.simpleName, "SSO server HTTP request failed ${e.statusCode}") + throw e + } catch (e: Exception) { + Log.d(javaClass.simpleName, "SSO server request error $e") + } + return VersatileProjectSyncClient.ResponseData(strBase64, "", 0) + } + + @Throws(IOException::class, NextcloudHttpRequestFailedException::class) + private fun requestServer( + target: String, + method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean + ): VersatileProjectSyncClient.ResponseData { + val result = StringBuilder() + val targetURL = url + target.replace("^/".toRegex(), "") + Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") + val httpCon = SupportUtil.getHttpURLConnection(targetURL) + httpCon.requestMethod = method + if (needLogin) { + httpCon.setRequestProperty( + "Authorization", + "Basic " + Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP) + ) + } + httpCon.setRequestProperty("Connection", "Close") + httpCon.setRequestProperty("User-Agent", "cowspent-android/" + SupportUtil.getAppVersionName(context)) + if (lastETag != null && METHOD_GET == method) { + httpCon.setRequestProperty("If-None-Match", lastETag) + } + if (isOCSRequest) { + httpCon.setRequestProperty("OCS-APIRequest", "true") + httpCon.setRequestProperty("Accept", "application/json") + } + httpCon.connectTimeout = 10 * 1000 // 10 seconds + var paramData: ByteArray? = null + if (params != null) { + paramData = params.toString().toByteArray() + Log.d(javaClass.simpleName, "Params: $params") + httpCon.setFixedLengthStreamingMode(paramData.size) + httpCon.setRequestProperty("Content-Type", application_json) + httpCon.doOutput = true + val os = httpCon.outputStream + os.write(paramData) + os.flush() + os.close() + } + val responseCode = httpCon.responseCode + Log.d(javaClass.simpleName, "HTTP response code: $responseCode") + if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + throw ServerResponse.NotModifiedException() + } + if (responseCode >= 400) { + throw NextcloudHttpRequestFailedException(context, responseCode, IOException("")) + } + Log.i(TAG, "METHOD : $method") + val rd = BufferedReader(InputStreamReader(httpCon.inputStream)) + var line: String? + while (rd.readLine().also { line = it } != null) { + result.append(line) + } + val etag = httpCon.getHeaderField("ETag") + val lastModified = httpCon.getHeaderFieldDate("Last-Modified", 0) / 1000 + Log.i( + javaClass.simpleName, + "Result length: " + result.length + (if (paramData == null) "" else "; Request length: " + paramData.size) + ) + Log.d(javaClass.simpleName, "ETag: $etag; Last-Modified: $lastModified (${httpCon.getHeaderField("Last-Modified")})") + return VersatileProjectSyncClient.ResponseData(result.toString(), "", 0) + } + + @Throws(IOException::class, NextcloudHttpRequestFailedException::class) + private fun imageRequestServer( + target: String, + method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean + ): VersatileProjectSyncClient.ResponseData { + var strBase64: String + val targetURL = url + target.replace("^/".toRegex(), "") + val httpCon = SupportUtil.getHttpURLConnection( targetURL) + httpCon.requestMethod = method + if (needLogin) { + httpCon.setRequestProperty( + "Authorization", + "Basic " + Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP) + ) + } + httpCon.setRequestProperty("Connection", "Close") + httpCon.setRequestProperty("User-Agent", "Cowspent-android/" + SupportUtil.getAppVersionName(context)) + if (lastETag != null && METHOD_GET == method) { + httpCon.setRequestProperty("If-None-Match", lastETag) + } + if (isOCSRequest) { + httpCon.setRequestProperty("OCS-APIRequest", "true") + } + httpCon.connectTimeout = 10 * 1000 // 10 seconds + Log.d(javaClass.simpleName, "$method $targetURL") + var paramData: ByteArray? + if (params != null) { + paramData = params.toString().toByteArray() + Log.d(javaClass.simpleName, "Params: $params") + httpCon.setFixedLengthStreamingMode(paramData.size) + httpCon.setRequestProperty("Content-Type", application_json) + httpCon.doOutput = true + val os = httpCon.outputStream + os.write(paramData) + os.flush() + os.close() + } + val responseCode = httpCon.responseCode + Log.d(javaClass.simpleName, "HTTP response code: $responseCode") + if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + throw ServerResponse.NotModifiedException() + } + if (responseCode >= 400) { + throw NextcloudHttpRequestFailedException(context, responseCode, IOException("")) + } + Log.i(TAG, "METHOD : $method") + val selectedImage = BitmapFactory.decodeStream(httpCon.inputStream) + val stream = ByteArrayOutputStream() + selectedImage.compress(Bitmap.CompressFormat.PNG, 100, stream) + val byteArray = stream.toByteArray() + strBase64 = Base64.encodeToString(byteArray, 0) + return VersatileProjectSyncClient.ResponseData(strBase64, "", 0) + } + + companion object { + private val TAG = NextcloudClient::class.java.simpleName + const val METHOD_GET = "GET" + const val METHOD_POST = "POST" + private const val application_json = "application/json" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt new file mode 100644 index 0000000..c8416ee --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt @@ -0,0 +1,776 @@ +package net.helcel.cowspent.util + +import android.util.Log +import net.helcel.cowspent.model.DBAccountProject +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBBillOwer +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBPaymentMode +import net.helcel.cowspent.model.DBProject +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.xml.sax.SAXException +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException + + +/** + * Provides entity classes for handling server responses + */ +@Suppress("unused") +open class ServerResponse( + private val response: VersatileProjectSyncClient.ResponseData, + protected val isOcsResponse: Boolean +) { + private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + + class NotModifiedException : IOException() + + protected val content: String + get() = response.content + + val lastModified: Long + get() = response.lastModified + + @Throws(JSONException::class) + fun getResponseObjectData(): JSONObject { + val rawData = JSONObject(content) + if (!isOcsResponse) { + return rawData + } + val data = rawData.getJSONObject("ocs") + return data.getJSONObject("data") + } + + @Throws(JSONException::class) + fun getResponseArrayData(): JSONArray { + if (!isOcsResponse) { + return JSONArray(content) + } + val rawData = JSONObject(content) + val data = rawData.getJSONObject("ocs") + return data.getJSONArray("data") + } + + @Throws(JSONException::class) + fun getResponseStringData(): String { + if (!isOcsResponse) { + return content + } + val rawData = JSONObject(content) + val data = rawData.getJSONObject("ocs") + return data.getString("data") + } + + class ProjectResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : + ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val email: String + get() = getEmailFromJSON(getResponseObjectData()) + + @get:Throws(JSONException::class) + val name: String + get() = getNameFromJSON(getResponseObjectData()) + + @get:Throws(JSONException::class) + val deletionDisabled: Boolean + get() = getDeletionDisabledFromJSON(getResponseObjectData()) + + @get:Throws(JSONException::class) + val archivedTs: Long? + get() = getArchivedTsFromJSON(getResponseObjectData()) + + @get:Throws(JSONException::class) + val myAccessLevel: Int + get() = getMyAccessLevelFromJSON(getResponseObjectData()) + + @get:Throws(JSONException::class) + val currencyName: String + get() = getCurrencyNameFromJSON(getResponseObjectData()) + + @Throws(JSONException::class) + fun getMembers(projId: Long): List { + return getMembersFromJSON(getResponseObjectData(), projId) + } + + @Throws(JSONException::class) + fun getCategories(projId: Long): List { + return getCategoriesFromJSON(getResponseObjectData(), projId) + } + + @Throws(JSONException::class) + fun getPaymentModes(projId: Long): List { + return getPaymentModesFromJSON(getResponseObjectData(), projId) + } + + @Throws(JSONException::class) + fun getCurrencies(projId: Long): List { + return getCurrenciesFromJSON(getResponseObjectData(), projId) + } + } + + class CreateRemoteMemberResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean, + private val isJsonMember: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + + @get:Throws(JSONException::class) + val remoteMemberId: Long + get() = if (isJsonMember) + getRemoteMemberIdFromJSON(getResponseObjectData()) + else + getResponseStringData().toLong() + } + + class CreateRemoteCurrencyResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class EditRemoteCurrencyResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class DeleteRemoteCurrencyResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class EditRemoteProjectResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class EditRemoteMemberResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @Throws(JSONException::class) + fun getRemoteId(projectId: Long): Long { + return getMemberFromJSON(getResponseObjectData(), projectId).remoteId + } + } + + class EditRemoteBillResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class CreateRemoteBillResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class DeleteRemoteBillResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class DeleteRemoteProjectResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class CreateRemoteProjectResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class BillsResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : + ServerResponse(response, isOcsResponse) { + + @Throws(JSONException::class) + fun getBillsCospend(projId: Long, memberRemoteIdToId: Map): List { + return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId) + } + + @Throws(JSONException::class) + fun getBillsIHM(projId: Long, memberRemoteIdToId: Map): List { + return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId) + } + + + @get:Throws(JSONException::class) + val allBillIds: List + get() = getAllBillIdsFromJSON(getResponseObjectData()) + + @get:Throws(JSONException::class) + val syncTimestamp: Long + get() = getSyncTimestampFromJSON(getResponseObjectData()) + } + + class MembersResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : + ServerResponse(response, isOcsResponse) { + + @Throws(JSONException::class) + fun getMembers(projId: Long): List { + return getMembersFromJSONArray(getResponseArrayData(), projId) + } + } + + class AccountProjectsResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @Throws(JSONException::class) + fun getAccountProjects(ncUrl: String): List { + return getAccountProjectsFromJSONArray(getResponseArrayData(), ncUrl) + } + } + + class CapabilitiesResponse(response: VersatileProjectSyncClient.ResponseData) : + ServerResponse(response, true) { + + @get:Throws(IOException::class, JSONException::class) + val color: String? + get() = getColorFromJsonContent(JSONObject(content)) + + @get:Throws(JSONException::class) + val cospendVersion: String? + get() = getCospendVersionFromCapabilitiesContent(JSONObject(content)) + } + + class AvatarResponse(response: VersatileProjectSyncClient.ResponseData) : + ServerResponse(response, false) { + + @get:Throws(IOException::class) + val avatarString: String + get() = content + } + + @Throws(JSONException::class) + protected fun getPublicTokenFromJSON(json: JSONObject): String? { + if (json.has("code") && json.has("sharetoken")) { + val done = json.getInt("code") + val publicToken = json.getString("sharetoken") + if (done == 1) { + return publicToken + } + } + return null + } + + @Throws(JSONException::class) + protected fun getNameFromJSON(json: JSONObject): String { + return if (json.has("name") && !json.isNull("name")) { + json.getString("name") + } else "" + } + + @Throws(JSONException::class) + protected fun getDeletionDisabledFromJSON(json: JSONObject): Boolean { + return if (json.has("deletiondisabled")) { + json.getBoolean("deletiondisabled") + } else false + } + + @Throws(JSONException::class) + protected fun getArchivedTsFromJSON(json: JSONObject): Long? { + return if (json.has("archived_ts") && !json.isNull("archived_ts")) { + val ts = json.optLong("archived_ts", 0) + if (ts > 0) ts else null + } else null + } + + @Throws(JSONException::class) + protected fun getMyAccessLevelFromJSON(json: JSONObject): Int { + return if (json.has("myaccesslevel")) { + json.getInt("myaccesslevel") + } else DBProject.ACCESS_LEVEL_UNKNOWN + } + + @Throws(JSONException::class) + protected fun getCurrencyNameFromJSON(json: JSONObject): String { + return if (json.has("currencyname") && !json.isNull("currencyname")) { + json.getString("currencyname") + } else "" + } + + @Throws(JSONException::class) + protected fun getEmailFromJSON(json: JSONObject): String { + return if (json.has("contact_email") && !json.isNull("contact_email")) { + json.getString("contact_email") + } else "" + } + + @Throws(JSONException::class) + protected fun getMembersFromJSONArray(jsonMs: JSONArray, projId: Long): List { + val members: MutableList = ArrayList() + for (i in 0 until jsonMs.length()) { + val jsonM = jsonMs.getJSONObject(i) + members.add(getMemberFromJSON(jsonM, projId)) + } + return members + } + + @Throws(JSONException::class) + protected fun getCategoriesFromJSON(json: JSONObject, projId: Long): List { + val categories: MutableList = ArrayList() + if (json.has("categories") && json.get("categories") is JSONObject) { + val jsonCats = json.getJSONObject("categories") + val keys = jsonCats.keys() + while (keys.hasNext()) { + val key = keys.next() + if (jsonCats.get(key) is JSONObject) { + categories.add(getCategoryFromJSON(jsonCats.getJSONObject(key), key, projId)) + } + } + } + return categories + } + + @Throws(JSONException::class) + protected fun getCategoryFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBCategory { + val remoteId = remoteIdStr.toLong() + var name = "" + var color = "" + var icon = "" + if (json.has("color") && !json.isNull("color")) { + color = json.getString("color") + } + if (json.has("icon") && !json.isNull("icon")) { + icon = json.getString("icon") + } + if (json.has("name") && !json.isNull("name")) { + name = json.getString("name") + } + return DBCategory(0, remoteId, projId, name, icon, color) + } + + @Throws(JSONException::class) + protected fun getPaymentModesFromJSON(json: JSONObject, projId: Long): List { + val paymentModes: MutableList = ArrayList() + if (json.has("paymentmodes") && json.get("paymentmodes") is JSONObject) { + val jsonPms = json.getJSONObject("paymentmodes") + val keys = jsonPms.keys() + while (keys.hasNext()) { + val key = keys.next() + if (jsonPms.get(key) is JSONObject) { + paymentModes.add(getPaymentModeFromJSON(jsonPms.getJSONObject(key), key, projId)) + } + } + } + return paymentModes + } + + @Throws(JSONException::class) + protected fun getPaymentModeFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBPaymentMode { + val remoteId = remoteIdStr.toLong() + var name = "" + var color = "" + var icon = "" + if (json.has("color") && !json.isNull("color")) { + color = json.getString("color") + } + if (json.has("icon") && !json.isNull("icon")) { + icon = json.getString("icon") + } + if (json.has("name") && !json.isNull("name")) { + name = json.getString("name") + } + return DBPaymentMode(0, remoteId, projId, name, icon, color) + } + + @Throws(JSONException::class) + protected fun getCurrenciesFromJSON(json: JSONObject, projId: Long): List { + val currencies: MutableList = ArrayList() + if (json.has("currencies") && json.get("currencies") is JSONArray) { + val jsonCurs = json.getJSONArray("currencies") + for (i in 0 until jsonCurs.length()) { + if (jsonCurs.get(i) is JSONObject) { + currencies.add(getCurrencyFromJSON(jsonCurs.getJSONObject(i), projId)) + } + } + } + return currencies + } + + @Throws(JSONException::class) + protected fun getCurrencyFromJSON(json: JSONObject, projId: Long): DBCurrency { + var remoteId: Long = 0 + var name = "" + var exchangeRate = 1.0 + if (json.has("exchange_rate") && !json.isNull("exchange_rate")) { + exchangeRate = json.getDouble("exchange_rate") + } + if (json.has("id") && !json.isNull("id")) { + remoteId = json.getLong("id") + } + if (json.has("name") && !json.isNull("name")) { + name = json.getString("name") + } + return DBCurrency(0, remoteId, projId, name, exchangeRate, DBBill.STATE_OK) + } + + @Throws(JSONException::class) + protected fun getMembersFromJSON(json: JSONObject, projId: Long): List { + val members: MutableList = ArrayList() + if (json.has("members")) { + val jsonMs = json.getJSONArray("members") + for (i in 0 until jsonMs.length()) { + val jsonM = jsonMs.getJSONObject(i) + members.add(getMemberFromJSON(jsonM, projId)) + } + } + return members + } + + @Throws(JSONException::class) + protected fun getMemberFromJSON(json: JSONObject, projId: Long): DBMember { + var activated = true + var weight = 1.0 + var remoteId: Long = 0 + var name = "" + var r: Int? = null + var g: Int? = null + var b: Int? = null + var ncUserId: String? = null + if (!json.isNull("id")) { + remoteId = json.getLong("id") + } + if (!json.isNull("weight")) { + weight = json.getDouble("weight") + } + if (!json.isNull("activated")) { + activated = json.getBoolean("activated") + } + if (!json.isNull("name")) { + name = json.getString("name") + } + if (json.has("color") && !json.isNull("color")) { + val obj = json.get("color") + if (obj is String) { + val color = json.getString("color").replace("#", "") + if (color.length == 6) { + r = color.substring(0, 2).toInt(16) + g = color.substring(2, 4).toInt(16) + b = color.substring(4, 6).toInt(16) + } + } else if (obj is JSONObject) { + val color = json.getJSONObject("color") + if (color.has("r") && !color.isNull("r")) { + r = color.getInt("r") + } + if (color.has("g") && !color.isNull("g")) { + g = color.getInt("g") + } + if (color.has("b") && !color.isNull("b")) { + b = color.getInt("b") + } + } + } + if (json.has("userid") && !json.isNull("userid")) { + ncUserId = json.getString("userid") + } + return DBMember( + 0, remoteId, projId, name, activated, weight, DBBill.STATE_OK, + r, g, b, ncUserId, null + ) + } + + @Throws(JSONException::class) + protected fun getAllBillIdsFromJSON(json: JSONObject): List { + val billIds: MutableList = ArrayList() + if (json.has("allBillIds") && !json.isNull("allBillIds")) { + val jsonBillIds = json.getJSONArray("allBillIds") + for (i in 0 until jsonBillIds.length()) { + billIds.add(jsonBillIds.getLong(i)) + } + } + return billIds + } + + @Throws(JSONException::class) + protected fun getSyncTimestampFromJSON(json: JSONObject): Long { + var ts = 0L + if (json.has("timestamp") && !json.isNull("timestamp")) { + ts = json.getLong("timestamp") + } + return ts + } + + @Throws(JSONException::class) + protected fun getBillsFromJSONArray( + json: JSONArray, + projId: Long, + memberRemoteIdToId: Map + ): List { + val bills: MutableList = ArrayList() + for (i in 0 until json.length()) { + val jsonBill = json.getJSONObject(i) + bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId)) + } + return bills + } + + @Throws(JSONException::class) + protected fun getBillsFromJSONObject( + json: JSONObject, + projId: Long, + memberRemoteIdToId: Map + ): List { + val bills: List + if (json.has("bills") && !json.isNull("bills")) { + val jsonBills = json.getJSONArray("bills") + bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId) + } else { + bills = ArrayList() + } + return bills + } + + @Throws(JSONException::class) + protected fun getBillFromJSON( + json: JSONObject, + projId: Long, + memberRemoteIdToId: Map + ): DBBill { + var remoteId: Long = 0 + var payerRemoteId: Long + var payerId: Long = 0 + var amount = 0.0 + var dateStr: String + var date: Date + var timestamp: Long = 0 + var what = "" + var comment = "" + var repeat = DBBill.NON_REPEATED + var paymentMode = DBBill.PAYMODE_NONE + var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE + var categoryId = DBBill.CATEGORY_NONE + if (!json.isNull("id")) { + remoteId = json.getLong("id") + } + if (!json.isNull("payer_id")) { + payerRemoteId = json.getLong("payer_id") + payerId = memberRemoteIdToId[payerRemoteId] ?: 0 + } else if (!json.isNull("payer")) { + payerRemoteId = json.getLong("payer") + payerId = memberRemoteIdToId[payerRemoteId] ?: 0 + } + if (!json.isNull("amount")) { + amount = json.getDouble("amount") + } + // get timestamp in priority + if (!json.isNull("timestamp")) { + timestamp = json.getLong("timestamp") + } else if (!json.isNull("date")) { + dateStr = json.getString("date") + try { + date = sdf.parse(dateStr)!! + timestamp = date.time / 1000 + } catch (_: Exception) { + timestamp = 0 + } + } + if (!json.isNull("what")) { + what = json.getString("what") + } else if (!json.isNull("label")) { + what = json.getString("label") + } + if (!json.isNull("comment")) { + comment = json.getString("comment") + } + if (json.has("repeat") && !json.isNull("repeat")) { + repeat = json.getString("repeat") + } + if (json.has("paymentmode") && !json.isNull("paymentmode")) { + paymentMode = json.getString("paymentmode") + } + if (json.has("categoryid") && !json.isNull("categoryid")) { + categoryId = json.getInt("categoryid") + Log.d("PLOP", "LOADED CATTTTTTTTTTTT $categoryId") + } + if (json.has("paymentmodeid") && !json.isNull("paymentmodeid")) { + paymentModeRemoteId = json.getInt("paymentmodeid") + } + // old MB, new Cospend is ok as Cospend provides the old pm ID + // new MB, old Cospend => set payment mode ID from old one + if (DBBill.PAYMODE_NONE != paymentMode && "" != paymentMode && paymentModeRemoteId == DBBill.PAYMODE_ID_NONE) { + Log.d("PaymentMode", "old: $paymentMode and new: 0") + paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE + } + val bill = DBBill( + 0, remoteId, projId, payerId, amount, timestamp, what, + DBBill.STATE_OK, repeat, paymentMode, categoryId, comment, paymentModeRemoteId + ) + bill.billOwers = getBillOwersFromJson(json, memberRemoteIdToId) + return bill + } + + @Throws(JSONException::class) + protected fun getBillOwersFromJson( + json: JSONObject, + memberRemoteIdToId: Map + ): List { + val billOwers: MutableList = ArrayList() + if (json.has("owers")) { + val jsonOs = json.getJSONArray("owers") + for (i in 0 until jsonOs.length()) { + val obj = jsonOs.get(i) + val memberRemoteId = if (obj is JSONObject) { + obj.getLong("id") + } else { + jsonOs.getLong(i) + } + val memberLocalId = memberRemoteIdToId[memberRemoteId] ?: 0 + billOwers.add(DBBillOwer(0, 0, memberLocalId)) + } + } + return billOwers + } + + @Throws(JSONException::class) + protected fun getAccountProjectsFromJSONArray(jsonMs: JSONArray, ncUrl: String): List { + val accountProjects: MutableList = ArrayList() + for (i in 0 until jsonMs.length()) { + val jsonAP = jsonMs.getJSONObject(i) + accountProjects.add(getAccountProjectFromJSON(jsonAP, ncUrl)) + } + return accountProjects + } + + @Throws(JSONException::class) + protected fun getAccountProjectFromJSON(json: JSONObject, accountNcUrl: String): DBAccountProject { + var remoteId = "" + var name = "" + var ncUrl = "" + if (!json.isNull("name")) { + name = json.getString("name") + } + if (!json.isNull("id")) { + remoteId = json.getString("id") + } + if (!json.isNull("ncurl")) { + ncUrl = json.getString("ncUrl") + } + val archivedTs: Long? = getArchivedTsFromJSON(json) + if (ncUrl.isEmpty()) { + ncUrl = accountNcUrl + } + return DBAccountProject(0, remoteId, null, name, ncUrl, archivedTs) + } + + @Throws(IOException::class) + protected fun getColorFromContent(content: String): String? { + var result: String? = null + try { + val dbf = DocumentBuilderFactory.newInstance() + val db = dbf.newDocumentBuilder() + val stream: InputStream = ByteArrayInputStream(content.toByteArray()) + val doc = db.parse(stream) + doc.documentElement.normalize() + // Locate the Tag Name + val nodeList = doc.getElementsByTagName("color") + if (nodeList.length > 0) { + result = nodeList.item(0).textContent + Log.i(TAG, "I GOT THE COLOR from server: $result") + } + } catch (_: ParserConfigurationException) { + } catch (_: SAXException) { + } + return result + } + + protected fun getColorFromJsonContent(json: JSONObject): String? { + return try { + val ocs = json.getJSONObject("ocs") + val data = ocs.getJSONObject("data") + val capabilities = data.getJSONObject("capabilities") + val theming = capabilities.getJSONObject("theming") + val color = theming.getString("color") + Log.i(TAG, "I GOT THE COLOR from server's JSON response: $color") + color + } catch (e: JSONException) { + Log.e(TAG, "Failed to get the color from OCS capabilities response $e") + null + } + } + + protected fun getCospendVersionFromCapabilitiesContent(json: JSONObject): String? { + return try { + val ocs = json.getJSONObject("ocs") + val data = ocs.getJSONObject("data") + val capabilities = data.getJSONObject("capabilities") + val cospend = capabilities.getJSONObject("cospend") + val version = cospend.getString("version") + Log.i(TAG, "I GOT THE Cospend version: $version") + version + } catch (e: JSONException) { + Log.i(TAG, "Failed to get the Cospend version$e") + null + } + } + + @Throws(JSONException::class) + protected fun getRemoteMemberIdFromJSON(json: JSONObject): Long { + return json.getLong("id") + } + + companion object { + private val TAG = ServerResponse::class.java.simpleName + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt new file mode 100644 index 0000000..00766a4 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt @@ -0,0 +1,338 @@ +package net.helcel.cowspent.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import net.helcel.cowspent.model.* +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import java.text.NumberFormat +import java.util.Locale +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.round + +object SupportUtil { + + @JvmField + val normalNumberFormat: NumberFormat = NumberFormat.getInstance() + + @JvmField + val dotNumberFormat: NumberFormat = NumberFormat.getNumberInstance(Locale.UK) + + @JvmField + val commaNumberFormat: NumberFormat = NumberFormat.getNumberInstance(Locale.GERMANY) + + init { + normalNumberFormat.maximumFractionDigits = 2 + normalNumberFormat + dotNumberFormat.maximumFractionDigits = Int.MAX_VALUE + dotNumberFormat.isGroupingUsed = false + commaNumberFormat.maximumFractionDigits = Int.MAX_VALUE + commaNumberFormat.isGroupingUsed = false + } + + @JvmStatic + @Throws(MalformedURLException::class, IOException::class) + fun getHttpURLConnection(strUrl: String): HttpURLConnection { + val url = URL(strUrl) + val httpCon = url.openConnection() as HttpURLConnection + if (url.protocol == "https") { + val httpsCon = httpCon as HttpsURLConnection + try { + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, null, null) + httpsCon.sslSocketFactory = sslContext.socketFactory + } catch (e: NoSuchAlgorithmException) { + Log.e(SupportUtil::class.java.simpleName, "Exception", e) + } catch (e: KeyManagementException) { + Log.e(SupportUtil::class.java.simpleName, "Exception", e) + } + } + return httpCon + } + + @JvmStatic + fun isDouble(s: String?): Boolean { + if (s == null) return false + return try { + s.toDouble() + true + } catch (_: NumberFormatException) { + false + } + } + + @JvmStatic + fun isValidEmail(target: CharSequence?): Boolean { + return if (target == null) false else android.util.Patterns.EMAIL_ADDRESS.matcher(target).matches() + } + + @JvmStatic + fun getStatsOfProject( + projId: Long, db: CowspentSQLiteOpenHelper, + membersNbBills: MutableMap, + membersBalance: MutableMap, + membersPaid: MutableMap, + membersSpent: MutableMap, + catId: Int, paymentModeId: Int, + dateMin: String?, dateMax: String? + ): Int { + return getStats( + db.getMembersOfProject(projId, null), + db.getBillsOfProject(projId), + membersNbBills, membersBalance, membersPaid, membersSpent, + catId, paymentModeId, dateMin, dateMax + ) + } + + @JvmStatic + fun getStats( + dbMembers: List, + dbBills: List, + membersNbBills: MutableMap, + membersBalance: MutableMap, + membersPaid: MutableMap, + membersSpent: MutableMap, + catId: Int, paymentModeId: Int, + dateMin: String?, dateMax: String? + ): Int { + val nbBillsTotal = 0 + val membersWeight: MutableMap = HashMap() + + // init + for (m in dbMembers) { + membersNbBills[m.id] = 0 + membersBalance[m.id] = 0.0 + membersPaid[m.id] = 0.0 + membersSpent[m.id] = 0.0 + membersWeight[m.id] = m.weight + } + + for (b in dbBills) { + // don't take deleted bills and respect category filter + if (b.state != DBBill.STATE_DELETED && + ((catId == -1000 || catId == -100 || b.categoryRemoteId == catId) && + (catId != -100 || b.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT) && + (paymentModeId == -1000 || b.paymentModeRemoteId == paymentModeId)) && + (dateMin == null || b.date >= dateMin) && + (dateMax == null || b.date <= dateMax) + ) { + val nb = membersNbBills[b.payerId] ?: 0 + membersNbBills[b.payerId] = nb + 1 + val amount = b.amount + val balPayer = membersBalance[b.payerId] ?: 0.0 + membersBalance[b.payerId] = balPayer + amount + val paid = membersPaid[b.payerId] ?: 0.0 + membersPaid[b.payerId] = paid + amount + + var nbOwerShares = 0.0 + for (bo in b.billOwers) { + nbOwerShares += membersWeight[bo.memberId] ?: 0.0 + } + for (bo in b.billOwers) { + val owerWeight = membersWeight[bo.memberId] ?: 0.0 + val spent = if (nbOwerShares > 0) amount / nbOwerShares * owerWeight else 0.0 + val balOwer = membersBalance[bo.memberId] ?: 0.0 + membersBalance[bo.memberId] = balOwer - spent + val spentOwer = membersSpent[bo.memberId] ?: 0.0 + membersSpent[bo.memberId] = spentOwer + spent + } + } + } + return nbBillsTotal + } + + @JvmStatic + fun round2(n: Double): Double { + var r = round(abs(n) * 100.0) / 100.0 + if (n < 0.0) r = -r + return r + } + + const val SETTLE_OPTIMAL: Long = 0 + + @JvmStatic + fun settleBills( + members: List, membersBalance: Map, + centerOnMemberId: Long + ): List { + return if (centerOnMemberId == SETTLE_OPTIMAL) { + settleBillsOptimal(members, membersBalance) + } else { + val results: MutableList = ArrayList() + for (mid in membersBalance.keys) { + if (mid != centerOnMemberId) { + val balance = membersBalance[mid] ?: 0.0 + if (balance > 0.0) { + results.add(Transaction(centerOnMemberId, mid, balance)) + } else if (balance < 0.0) { + results.add(Transaction(mid, centerOnMemberId, -balance)) + } + } + } + results + } + } + + @JvmStatic + fun settleBillsOptimal(members: List, membersBalance: Map): List { + val crediters: MutableList = ArrayList() + val debiters: MutableList = ArrayList() + + // Create lists of credits and debts + for (m in members) { + val memberId = m.id + val balance = membersBalance[memberId] ?: 0.0 + + if (round2(balance) > 0.0) { + crediters.add(CreditDebt(memberId, balance)) + } else if (round2(balance) < 0.0) { + debiters.add(CreditDebt(memberId, balance)) + } + } + + return reduceBalance(crediters, debiters, null) + } + + @JvmStatic + fun reduceBalance( + crediters: MutableList, + debiters: MutableList, + resultsParam: MutableList? + ): List { + var results = resultsParam + if (debiters.isEmpty() || crediters.isEmpty()) { + return results ?: emptyList() + } + + if (results == null) { + results = ArrayList() + } + + crediters.sortWith { cd2, cd1 -> + if (cd1.balance == cd2.balance) { + 0 + } else { + if (cd1.balance < cd2.balance) 1 else -1 + } + } + + for (c in crediters) { + Log.e(SupportUtil::class.java.simpleName, "* " + c.memberId + " : " + c.balance) + } + + debiters.sortWith { cd2, cd1 -> + if (cd1.balance == cd2.balance) { + 0 + } else { + if (cd1.balance > cd2.balance) 1 else -1 + } + } + + val deb = debiters.removeAt(debiters.size - 1) + val debiter = deb.memberId + val debiterBalance = deb.balance + + val cred = crediters.removeAt(crediters.size - 1) + val crediter = cred.memberId + val crediterBalance = cred.balance + + val amount: Double = if (abs(debiterBalance) > abs(crediterBalance)) { + abs(crediterBalance) + } else { + abs(debiterBalance) + } + + results.add(Transaction(debiter, crediter, amount)) + + val newDebiterBalance = debiterBalance + amount + if (newDebiterBalance < 0.0) { + debiters.add(CreditDebt(debiter, newDebiterBalance)) + debiters.sortWith { cd2, cd1 -> + if (cd1.balance == cd2.balance) { + 0 + } else { + if (cd1.balance > cd2.balance) 1 else -1 + } + } + } + + val newCrediterBalance = crediterBalance - amount + if (newCrediterBalance > 0.0) { + crediters.add(CreditDebt(crediter, newCrediterBalance)) + crediters.sortWith { cd2, cd1 -> + if (cd1.balance == cd2.balance) { + 0 + } else { + if (cd1.balance < cd2.balance) 1 else -1 + } + } + } + + return reduceBalance(crediters, debiters, results) + } + + @JvmStatic + fun getVersionName(context: Context): String { + var versionName = "0.0.0" + try { + val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) + versionName = pInfo.versionName ?: "0.0.0" + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + } + return versionName + } + + @JvmStatic + fun getJsonObject(text: String?): JSONObject? { + if (text == null) return null + return try { + JSONObject(text) + } catch (_: JSONException) { + null + } + } + + @JvmStatic + fun compareVersions(version1: String, version2: String): Int { + val levels1 = version1.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val levels2 = version2.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val length = max(levels1.size, levels2.size) + for (i in 0 until length) { + val v1 = if (i < levels1.size) levels1[i].toInt() else 0 + val v2 = if (i < levels2.size) levels2[i].toInt() else 0 + val compare = v1.compareTo(v2) + if (compare != 0) { + return compare + } + } + return 0 + } + + @JvmStatic + fun getAppVersionName(context: Context): String { + var versionName = "???" + try { + val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) + versionName = pInfo.versionName ?: "0.0.0" + } catch (e: PackageManager.NameNotFoundException) { + Log.e(SupportUtil::class.java.simpleName, "Failed to get app version name", e) + e.printStackTrace() + } + Log.d(SupportUtil::class.java.simpleName, "app version name is $versionName") + return versionName + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt new file mode 100644 index 0000000..ac0a4df --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -0,0 +1,1130 @@ +package net.helcel.cowspent.util + +import android.content.Context +import android.util.Base64 +import android.util.Log +import androidx.annotation.WorkerThread +import com.nextcloud.android.sso.QueryParam +import com.nextcloud.android.sso.aidl.NextcloudRequest +import com.nextcloud.android.sso.api.NextcloudAPI +import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException +import com.nextcloud.android.sso.exceptions.TokenMismatchException +import com.nextcloud.android.sso.model.SingleSignOnAccount +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType +import org.json.JSONException +import java.io.* +import java.net.HttpURLConnection +import java.net.URLEncoder + +@WorkerThread +class VersatileProjectSyncClient( + private val url: String, + private val username: String, + private val password: String, + private val nextcloudAPI: NextcloudAPI?, + private val ssoAccount: SingleSignOnAccount?, + cospendVersion: String?, + private val context: Context +) { + + /** + * This entity class is used to return relevant data of the HTTP response. + */ + class ResponseData(val content: String, val eTag: String?, val lastModified: Long) + + private val cospendVersionGT161: Boolean = if (cospendVersion == null) { + Log.i(TAG, "GT161 is FALSE") + false + } else { + val gt = SupportUtil.compareVersions(cospendVersion, "1.6.1") >= 0 + Log.i(TAG, "GT161: $gt") + gt + } + + fun canAccessProjectWithNCLogin(project: DBProject): Boolean { + return (project.password == "" + && url.replace("/+$".toRegex(), "") != "" + && project.serverUrl!! + .replace("/index.php/apps/cospend", "") == url.replace("/+$".toRegex(), "") + ) + } + + fun canAccessProjectWithSSO(project: DBProject): Boolean { + return (project.password == "" + && ssoAccount != null + && project.serverUrl!!.replace("/index.php/apps/cospend", "") == ssoAccount.url + ) + } + + @Throws(UnsupportedEncodingException::class) + private fun getEncodedPassword(password: String): String { + return URLEncoder.encode(password, "utf-8").replace("+", "%20") + } + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getProject(project: DBProject, lastModified: Long, lastETag: String?): ServerResponse.ProjectResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for getProjectInfo") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + Log.i(TAG, "using new API for getProjectInfo") + ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + Log.i(TAG, "using public API, target is: ${target}for getProjectInfo") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.ProjectResponse( + requestServer( + target, METHOD_GET, null, null, lastETag, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun editRemoteProject( + project: DBProject, newName: String?, newEmail: String?, newPassword: String?, + newMainCurrencyName: String? + ): ServerResponse.EditRemoteProjectResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + if (newName != null) { + paramKeys.add("name") + paramValues.add(newName) + } + if (newEmail != null) { + paramKeys.add("contact_email") + paramValues.add(newEmail) + } + if (newPassword != null) { + paramKeys.add("password") + paramValues.add(newPassword) + } + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (newMainCurrencyName != null) { + paramKeys.add("currencyname") + paramValues.add(newMainCurrencyName) + } + if (cospendVersionGT161) { + paramKeys.clear() + paramValues.clear() + paramKeys.add("id") + paramValues.add(project.remoteId) + if (newName != null) { + paramKeys.add("name") + paramValues.add(newName) + } + if (newPassword != null) { + paramKeys.add("password") + paramValues.add(newPassword) + } + if (newMainCurrencyName != null) { + paramKeys.add("currencyName") + paramValues.add(newMainCurrencyName) + } + } + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteProject") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + Log.i(TAG, "using new API for editRemoteProject") + ServerResponse.EditRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + ServerResponse.EditRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + Log.i(TAG, "using public API, target is: ${target}for editRemoteProject") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.EditRemoteProjectResponse( + requestServer( + target, METHOD_PUT, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun editRemoteMember(project: DBProject, member: DBMember): ServerResponse.EditRemoteMemberResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(member.name) + paramKeys.add("weight") + paramValues.add(member.weight.toString()) + paramKeys.add("activated") + paramValues.add(if (member.isActivated) "true" else "false") + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + val r = member.r + val g = member.g + val b = member.b + if (r != null && g != null && b != null) { + val hexColor = "#" + Integer.toHexString(r) + Integer.toHexString(g) + Integer.toHexString(b) + paramKeys.add("color") + paramValues.add(hexColor) + } + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/members/" + member.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteMember") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members/" + member.remoteId + Log.i(TAG, "using new API for editRemoteMember") + ServerResponse.EditRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId + ServerResponse.EditRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId + Log.i(TAG, "using public API, target is: ${target}for editRemoteMember") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members/" + member.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.EditRemoteMemberResponse( + requestServer( + target, METHOD_PUT, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun editRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map): ServerResponse.EditRemoteBillResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("date") + paramValues.add(bill.date) + if (project.type == ProjectType.IHATEMONEY) { + paramKeys.add("label") + } else { + paramKeys.add("what") + } + paramValues.add(bill.what) + paramKeys.add("payer") + paramValues.add(memberIdToRemoteId[bill.payerId].toString()) + paramKeys.add("amount") + paramValues.add(SupportUtil.dotNumberFormat.format(bill.amount)) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + paramKeys.add("timestamp") + paramValues.add(bill.timestamp.toString()) + paramKeys.add("comment") + paramValues.add(bill.comment ?: "") + paramKeys.add("repeat") + paramValues.add(bill.repeat ?: "") + + if (cospendVersionGT161) { + paramKeys.add("payedFor") + paramKeys.add("paymentMode") + paramKeys.add("categoryId") + paramKeys.add("paymentModeId") + } else { + paramKeys.add("payed_for") + paramKeys.add("paymentmode") + paramKeys.add("categoryid") + paramKeys.add("paymentmodeid") + } + var payedFor = "" + for (boId in bill.billOwersIds) { + payedFor += memberIdToRemoteId[boId].toString() + "," + } + payedFor = payedFor.replace(",$".toRegex(), "") + paramValues.add(payedFor) + paramValues.add(bill.paymentMode ?: "") + paramValues.add(bill.categoryRemoteId.toString()) + paramValues.add(bill.paymentModeRemoteId.toString()) + + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/bills/" + bill.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteBill") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + bill.remoteId + Log.i(TAG, "using new API for editRemoteBill") + ServerResponse.EditRemoteBillResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId + ServerResponse.EditRemoteBillResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId + Log.i(TAG, "using public API, target is: ${target}for editRemoteBill") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + + val owerKey = if (project.type == ProjectType.IHATEMONEY) "owers" else "payed_for" + for (boId in bill.billOwersIds) { + paramKeys.add(owerKey) + paramValues.add(memberIdToRemoteId[boId].toString()) + } + } + return ServerResponse.EditRemoteBillResponse( + requestServer( + target, METHOD_PUT, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun deleteRemoteProject(project: DBProject): ServerResponse.DeleteRemoteProjectResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteProject") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + Log.i(TAG, "using new API for deleteRemoteProject") + ServerResponse.DeleteRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + ServerResponse.DeleteRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.DeleteRemoteProjectResponse( + requestServer( + target, METHOD_DELETE, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun deleteRemoteBill(project: DBProject, billRemoteId: Long): ServerResponse.DeleteRemoteBillResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/bills/" + billRemoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + billRemoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteBill") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + billRemoteId + Log.i(TAG, "using new API for deleteRemoteProject") + ServerResponse.DeleteRemoteBillResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills/" + billRemoteId + ServerResponse.DeleteRemoteBillResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId + Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.DeleteRemoteBillResponse( + requestServer( + target, METHOD_DELETE, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, NextcloudHttpRequestFailedException::class) + fun createAnonymousRemoteProject(project: DBProject): ServerResponse.CreateRemoteProjectResponse { + val target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects" + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(project.name) + paramKeys.add("contact_email") + paramValues.add(project.email ?: "") + paramKeys.add("password") + paramValues.add(project.password) + paramKeys.add("id") + paramValues.add(project.remoteId) + return ServerResponse.CreateRemoteProjectResponse( + requestServer( + target, METHOD_POST, paramKeys, paramValues, + null, null, null, null, false + ), false + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun createAuthenticatedRemoteProject(project: DBProject): ServerResponse.CreateRemoteProjectResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(project.name) + paramKeys.add("contact_email") + paramValues.add(project.email ?: "") + paramKeys.add("password") + paramValues.add(project.password) + paramKeys.add("id") + paramValues.add(project.remoteId) + + var target: String + var username: String? + var password: String? + var useOcsApiRequest: Boolean + if (ssoAccount != null) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects" + Log.i(TAG, "using new API for createAuthenticatedRemoteProject") + ServerResponse.CreateRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects" + ServerResponse.CreateRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, false), false) + } + } else { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects" + else + project.getRequestBaseUrl(false) + "/api-priv/projects" + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for createAuthenticatedRemoteProject") + } + return ServerResponse.CreateRemoteProjectResponse( + requestServer( + target, METHOD_POST, paramKeys, paramValues, + null, username, password, null, useOcsApiRequest + ), useOcsApiRequest + ) + } + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun createRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map): ServerResponse.CreateRemoteBillResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("date") + paramValues.add(bill.date) + paramKeys.add("what") + paramValues.add(bill.what) + paramKeys.add("payer") + paramValues.add(memberIdToRemoteId[bill.payerId].toString()) + paramKeys.add("amount") + paramValues.add(SupportUtil.dotNumberFormat.format(bill.amount)) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + paramKeys.add("comment") + paramValues.add(bill.comment ?: "") + paramKeys.add("timestamp") + paramValues.add(bill.timestamp.toString()) + paramKeys.add("repeat") + paramValues.add(bill.repeat ?: "") + if (cospendVersionGT161) { + paramKeys.add("payedFor") + paramKeys.add("paymentMode") + paramKeys.add("categoryId") + paramKeys.add("paymentModeId") + } else { + paramKeys.add("payed_for") + paramKeys.add("paymentmode") + paramKeys.add("categoryid") + paramKeys.add("paymentmodeid") + } + var payedFor = "" + for (boId in bill.billOwersIds) { + payedFor += memberIdToRemoteId[boId].toString() + "," + } + payedFor = payedFor.replace(",$".toRegex(), "") + paramValues.add(payedFor) + paramValues.add(bill.paymentMode ?: "") + paramValues.add(bill.categoryRemoteId.toString()) + paramValues.add(bill.paymentModeRemoteId.toString()) + + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/bills" + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills" + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteBill") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" + Log.i(TAG, "using new API for createRemoteBill") + ServerResponse.CreateRemoteBillResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills" + ServerResponse.CreateRemoteBillResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" + Log.i(TAG, "using public API, target is: ${target}for createRemoteBill") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + + val owerKey = "payed_for" + for (boId in bill.billOwersIds) { + paramKeys.add(owerKey) + paramValues.add(memberIdToRemoteId[boId].toString()) + } + } + + return ServerResponse.CreateRemoteBillResponse( + requestServer( + target, METHOD_POST, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun createRemoteMember(project: DBProject, member: DBMember): ServerResponse.CreateRemoteMemberResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(member.name) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + val r = member.r + val g = member.g + val b = member.b + if (r != null && g != null && b != null) { + val hexColor = "#" + Integer.toHexString(r) + Integer.toHexString(g) + Integer.toHexString(b) + paramKeys.add("color") + paramValues.add(hexColor) + } + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/members" + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteMember") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" + Log.i(TAG, "using new API for createRemoteBill") + ServerResponse.CreateRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), isOcsResponse=true, isJsonMember=true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members" + ServerResponse.CreateRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, false), isOcsResponse=false, isJsonMember=false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" + Log.i(TAG, "using public API, target is: ${target}for createRemoteBill") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.CreateRemoteMemberResponse( + requestServer( + target, METHOD_POST, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest, + ProjectType.COSPEND == project.type && cospendVersionGT161 + ) + } + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getBills(project: DBProject): ServerResponse.BillsResponse { + var target: String + var username: String? + var password: String? + var bearerToken: String? + var useOcsApiRequest: Boolean + if (ProjectType.COSPEND == project.type) { + val tsLastSync = project.lastSyncedTimestamp + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/bills?lastChanged=" + tsLastSync + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for getBills") + } + return ServerResponse.BillsResponse( + requestServer( + target, METHOD_GET, null, null, + null, username, password, null, useOcsApiRequest + ), + useOcsApiRequest + ) + } else if (canAccessProjectWithSSO(project)) { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + if (cospendVersionGT161) { + paramKeys.add("lastChanged") + } else { + paramKeys.add("lastchanged") + } + paramValues.add(tsLastSync.toString()) + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" + Log.i(TAG, "using new API for getBills") + ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills" + ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync + else + project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync + Log.i(TAG, "using public API, target is: ${target}for getBills") + return ServerResponse.BillsResponse( + requestServer( + target, METHOD_GET, null, null, + null, null, null, null, useOcsApiRequest + ), + useOcsApiRequest + ) + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + return ServerResponse.BillsResponse( + requestServer( + target, METHOD_GET, null, null, + null, username, password, bearerToken, false + ), + false + ) + } + } + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getMembers(project: DBProject): ServerResponse.MembersResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/members" + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for getMembers, projectId: " + project.remoteId) + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" + Log.i(TAG, "using new API for getMembers") + ServerResponse.MembersResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members" + ServerResponse.MembersResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" + Log.i(TAG, "using public API, target is: ${target}for getMembers") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.MembersResponse( + requestServer( + target, METHOD_GET, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun createRemoteCurrency(project: DBProject, currency: DBCurrency): ServerResponse.CreateRemoteCurrencyResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(currency.name ?: "") + paramKeys.add("rate") + paramValues.add(currency.exchangeRate.toString()) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/currency" + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency" + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteCurrency") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency" + Log.i(TAG, "using new API for createRemoteCurrency") + ServerResponse.CreateRemoteCurrencyResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/currency" + ServerResponse.CreateRemoteCurrencyResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" + Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.CreateRemoteCurrencyResponse( + requestServer( + target, METHOD_POST, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun editRemoteCurrency(project: DBProject, currency: DBCurrency): ServerResponse.EditRemoteCurrencyResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(currency.name ?: "") + paramKeys.add("rate") + paramValues.add(currency.exchangeRate.toString()) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/currency/" + currency.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currency.remoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteCurrency") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currency.remoteId + Log.i(TAG, "using new API for createRemoteCurrency") + ServerResponse.EditRemoteCurrencyResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/currency/" + currency.remoteId + ServerResponse.EditRemoteCurrencyResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId + Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.EditRemoteCurrencyResponse( + requestServer( + target, METHOD_PUT, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun deleteRemoteCurrency(project: DBProject, currencyRemoteId: Long): ServerResponse.DeleteRemoteCurrencyResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/currency/" + currencyRemoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currencyRemoteId + useOcsApiRequest = cospendVersionGT161 + if (cospendVersionGT161) { + Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteCurrency") + } + } else if (canAccessProjectWithSSO(project)) { + return if (cospendVersionGT161) { + target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currencyRemoteId + Log.i(TAG, "using new API for deleteRemoteCurrency") + ServerResponse.DeleteRemoteCurrencyResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, null, null, true), true) + } else { + target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/currency/" + currencyRemoteId + ServerResponse.DeleteRemoteCurrencyResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, null, null, false), false) + } + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId + Log.i(TAG, "using public API, target is: ${target}for deleteRemoteCurrency") + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.DeleteRemoteCurrencyResponse( + requestServer( + target, METHOD_DELETE, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + private fun requestServerWithSSO( + nextcloudAPI: NextcloudAPI, target: String, method: String, + paramKeys: List?, paramValues: List?, isOCSRequest: Boolean + ): ResponseData { + val result = StringBuilder() + var params: MutableList? = null + if (paramKeys != null && paramValues != null) { + params = ArrayList() + for (i in paramKeys.indices) { + params.add(QueryParam(paramKeys[i], paramValues[i])) + } + } + val headers: MutableMap> = HashMap() + if (isOCSRequest) { + val acceptHeader: MutableList = ArrayList() + acceptHeader.add("application/json") + headers["Accept"] = acceptHeader + } + val nextcloudRequest: NextcloudRequest = if (params == null) { + NextcloudRequest.Builder() + .setMethod(method) + .setUrl(target) + .setHeader(headers) + .build() + } else { + NextcloudRequest.Builder() + .setMethod(method) + .setUrl(target) + .setParameter(params) + .setHeader(headers) + .build() + } + try { + val response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest) + val inputStream = response.body + val rd = BufferedReader(InputStreamReader(inputStream)) + var line: String? + while (rd.readLine().also { line = it } != null) { + result.append(line) + } + Log.d(javaClass.simpleName, "RES versatile $result") + inputStream.close() + } catch (e: TokenMismatchException) { + Log.d(javaClass.simpleName, "Mismatcho SSO server request error $e") + throw e + } catch (e: NextcloudHttpRequestFailedException) { + Log.d(javaClass.simpleName, "SSO server HTTP request failed ${e.statusCode}") + throw e + } catch (e: Exception) { + Log.d(javaClass.simpleName, "SSO server request error $e") + } + return ResponseData(result.toString(), "", 0) + } + + @Throws(IOException::class, NextcloudHttpRequestFailedException::class) + private fun requestServer( + target: String, method: String, + paramKeys: List?, paramValues: List?, + lastETag: String?, username: String?, password: String?, + bearerToken: String?, isOCSRequest: Boolean + ): ResponseData { + val result = StringBuilder() + val httpCon = SupportUtil.getHttpURLConnection(target) + httpCon.requestMethod = method + if (bearerToken != null) { + httpCon.setRequestProperty("Authorization", "Bearer $bearerToken") + } else if (username != null) { + httpCon.setRequestProperty( + "Authorization", + "Basic " + Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP) + ) + } + httpCon.setRequestProperty("Connection", "Close") + httpCon.setRequestProperty("User-Agent", "Cowspent/" + SupportUtil.getAppVersionName(context)) + if (lastETag != null && METHOD_GET == method) { + httpCon.setRequestProperty("If-None-Match", lastETag) + } + if (isOCSRequest) { + httpCon.setRequestProperty("OCS-APIRequest", "true") + httpCon.setRequestProperty("Accept", "application/json") + } + httpCon.connectTimeout = 10 * 1000 // 10 seconds + Log.d(javaClass.simpleName, "$method $target") + if (paramKeys != null && paramValues != null) { + var dataString = "" + for (i in paramKeys.indices) { + val key = paramKeys[i] + val value = paramValues[i] + if (dataString.isNotEmpty()) { + dataString += "&" + } + dataString += URLEncoder.encode(key, "UTF-8") + "=" + dataString += URLEncoder.encode(value, "UTF-8") + } + val data = dataString.toByteArray() + Log.d(javaClass.simpleName, "Params: $dataString") + httpCon.setFixedLengthStreamingMode(data.size) + httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + httpCon.setRequestProperty("Content-Length", data.size.toString()) + httpCon.doOutput = true + val os = httpCon.outputStream + os.write(data) + os.flush() + os.close() + } + val responseCode = httpCon.responseCode + Log.d(javaClass.simpleName, "HTTP response code: $responseCode") + if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + throw ServerResponse.NotModifiedException() + } + Log.d(TAG, "METHOD : $method") + val rd: BufferedReader = if (responseCode in 200..399) { + BufferedReader(InputStreamReader(httpCon.inputStream)) + } else { + Log.e(TAG, "ERROR CODE : $responseCode") + BufferedReader(InputStreamReader(httpCon.errorStream)) + } + var line: String? + while (rd.readLine().also { line = it } != null) { + result.append(line) + } + if (responseCode >= 400) { + throw NextcloudHttpRequestFailedException(context, responseCode, IOException(result.toString())) + } + val etag = httpCon.getHeaderField("ETag") + val lastModified = httpCon.getHeaderFieldDate("Last-Modified", 0) / 1000 + Log.i(TAG, "Result length: " + result.length + (if (paramKeys == null) "" else "; Request length: " + result.length)) + Log.d(TAG, "ETag: $etag; Last-Modified: $lastModified (${httpCon.getHeaderField("Last-Modified")})") + return ResponseData(result.toString(), etag, lastModified) + } + + companion object { + private val TAG = VersatileProjectSyncClient::class.java.simpleName + const val METHOD_GET = "GET" + const val METHOD_POST = "POST" + const val METHOD_PUT = "PUT" + const val METHOD_DELETE = "DELETE" + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/evalMath.kt b/app/src/main/java/net/helcel/cowspent/util/evalMath.kt new file mode 100644 index 0000000..e81103a --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/evalMath.kt @@ -0,0 +1,21 @@ +package net.helcel.cowspent.util +import android.database.sqlite.SQLiteDatabase + +fun evalMath(expression: String): Double { + var result = 0.0 + var db: SQLiteDatabase? = null + try { + // Opens a temporary, in-memory system database block + db = SQLiteDatabase.create(null) + val cursor = db.rawQuery("SELECT ($expression);", null) + if (cursor.moveToFirst()) { + result = cursor.getDouble(0) + } + cursor.close() + } catch (e: Exception) { + e.printStackTrace() + } finally { + db?.close() + } + return result +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..724b0df --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + 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..030fd29 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/test.xml b/app/src/main/res/drawable/test.xml new file mode 100644 index 0000000..1828a39 --- /dev/null +++ b/app/src/main/res/drawable/test.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..50ec886 --- /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..50ec886 --- /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/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..a6d1f9c --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1,2 @@ +# https://developer.android.com/guide/topics/resources/app-languages +unqualifiedResLocale=en \ No newline at end of file diff --git a/app/src/main/res/values-night/booleans.xml b/app/src/main/res/values-night/booleans.xml new file mode 100644 index 0000000..41cc6fe --- /dev/null +++ b/app/src/main/res/values-night/booleans.xml @@ -0,0 +1,4 @@ + + + false + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..4ba5bdf --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,11 @@ + + + #72D1FF + #000000 + #72D1FF + #121212 + #ffffff + #121212 + #ffffff + #aaaaaa + diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml new file mode 100644 index 0000000..b479681 --- /dev/null +++ b/app/src/main/res/values-v27/styles.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/booleans.xml b/app/src/main/res/values/booleans.xml new file mode 100644 index 0000000..c47017c --- /dev/null +++ b/app/src/main/res/values/booleans.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..dd5894e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,14 @@ + + + #0082C9 + #ffffff + #0082C9 + #ffffff + #000000 + + #ffffff + #000000 + #666666 + #00CC00 + #CC0000 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..1d514eb --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 0dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..00ff0c4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,282 @@ + + + + Cowspent + All bills + New bill + App settings + Scan QRCode from Cospend/Cowspent + Add project + Save bill + Edit project + Share project + What? + Who paid? + All + None + + Bill edition + Bill values have been changed. What do you want to do? + Save + Discard + Currency manager + Add currency + Currency management is only available for Cospend projects + Comment + Convert current amount from another currency into %s + There is no additional currency + Main currency + Name + Exchange rate + For whom? + Project ID/name + + Repeat this bill every + Mode + Category + + Label missing categories + Label Bills + No more bills to label + Skip + Suggested + No suggestions for this bill + What do you want to do? + Where? + Local + Cospend + ihatemoney + ihatemoney address + Join + Create + Nextcloud address + Password (local) + Password + Project title + E-mail + Warning: creating a Cospend project using Nextcloud account works with Cospend v0.3.4 and higher. + Authenticated project creation + Nothing was changed + Project successfully added to Cowspent + Search + Delete + Error + Close + Loading + Project sync failed + Synchronization with project %1$s failed. Error message:\n\n%2$s\n\nThe associated remote project might not exist anymore. If so, you could remove this project from Cowspent. + The Nextcloud instance is probably in maintenance mode + 400 Bad request + 401 Unauthorized + 403 Forbidden + 404 Not Found + Creating remote project + Cancel + Ok + Yes + No + Add + Edit bill + New bill + Add project in Cowspent + Edit project + About + Impossible to share this project + Share + Create bills + Settlement bill + Share Cowspent link + Delete project on server + Save project + Add a member + Remove Project + Project %1$s has been removed + Manage project + Manage members + Export project + Last sync: %1$02d:%2$02d + Scan QR code + Choose from Nextcloud account + Import from CSV file + Malformed CSV, bad owers on line %1$d + Malformed CSV, bad date on line %1$d + Malformed CSV, bad column names on line %1$d + This member already exists in this project + Are you sure? + Are you sure you want to delete remote project? + The remote project will NOT be deleted + Activated + Name + Color + Weight + Delete member + Remote project operations require network connectivity + You are not allowed to perform this action + + + Appearance + Network + Other + About Cowspent + Nextcloud account + Logged in as %1$s + Logout + Server address + WARNING: "http" is unsafe. Please use "https". + Username + Password + Connect + Theme + Offline mode + Only sync manually. + Custom color + UI Accent color + Choose color + Color Selection + System + Nextcloud + Manual + + + Main currency saved + Project saved in %s + New member name + Edit member + Optimal + Who pays? + To whom? + How much? + Total Spent: %s + From + To + Who + Paid + Spent + Balance + Statistics + Settle + Project %1$s was successfully added + Project %1$s was successfully created + Project added + Project created + Statistics + Settle the project + The expenses in this project are already balanced. There is nothing to settle! + Share the project + Scan this QRCode with Cowspent or a scanner app on an Android device where Cowspent is installed. You can also simply send/share the Cowspent link above. + Share this web link to let others access the project with a web browser. + Web link + Cowspent link + Share this link to a Cowspent user. + Cowspent link of %1$s + Share Cowspent link of %1$s + Here is how to settle project %1$s : + %1$s owes %3$.2f to %2$s + Settle the project %1$s + Statistics of project %1$s : + MEMBER_NAME (PAID | SPENT | BALANCE) + Click here to connect to a Nextcloud account + At least one activated member is required to add a bill + Impossible for local projects + Choose a project from your Nextcloud account + There is no Cospend project in your Nextcloud account + Activity in project %1$s + + + No Projects + You don\'t have any projects yet. If you have Cospend installed on a Nextcloud server, you can configure your Nextcloud account. Alternatively, you can manually add projects. + Configure Nextcloud account + Manually add a project (Cospend or local) + No Members + This project doesn\'t have any members. In order to create bills, you need to add at least one member (better two or more). + No Bills + This project doesn\'t have any bills. Click the button below to add your first bill. + + + + + + + Impossible to reach the remote project:\n\n%1$s + Project sync failed: %1$s + Invalid login: %1$s + is the Cospend app activated on the server? + server connection is broken + no network connection + server error + wrong username or password + Invalid e-mail address + Invalid project ID/name + Invalid project title + Invalid bill name + Invalid bill date + Bill should have a payer + Bill should have at least one ower + Error at remote project creation : %s + Error at remote project edition : %s + Please reauthenticate your SSO account in settings + Request failed + + + Version %1$s + Maintainer + Helcel.net + License + GNU GPL3x / CC BY-SA 4.0 + Source code + https://github.com/helcel-net/cowspent + + + nightMode + appColor + serverColor + ncAvatar + useServerColor + useSystemColor + colorMode + offlineMode + showArchived + Light + Dark + Follow system + 1 + 2 + -1 + Use Nextcloud app account + + + QR code could not be scanned. + Manage currencies + Show archived projects + + + + No repeat + Daily + Weekly + Fortnightly + Monthly + Yearly + No payment mode + All payment modes + Credit card + Cash + Check + Online service + Transfer + No category + All categories + All except reimbursement + Grocery + Bar/Party + Rent + Bill + Excursion/Culture + Health + Shopping + Reimbursement + Restaurant + Accommodation + Transport + Sport + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..4a8d63a --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..ef899d4 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/searchable.xml b/app/src/main/res/xml/searchable.xml new file mode 100644 index 0000000..257d8f1 --- /dev/null +++ b/app/src/main/res/xml/searchable.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..696a913 --- /dev/null +++ b/build.gradle @@ -0,0 +1,8 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + kotlin_version = '2.2.10' + } +} + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6fe660e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,28 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +#org.gradle.jvmargs=-Xmx1024m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +org.gradle.dependency.verification.console=verbose + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +android.useAndroidX=true +android.nonTransitiveRClass=false +org.gradle.warning.mode=all +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt new file mode 100644 index 0000000..d045db8 --- /dev/null +++ b/metadata/en-US/full_description.txt @@ -0,0 +1,36 @@ +Cowspent is a shared budget manager able to synchronize with Nextcloud Cospend. It is partially based on MoneyBuster + +What did we change from MoneyBuster ? + +We added more statistics, support for projects marked as archived (hidden by default), +added support for custom splits to bills, added a way to add categories to bills missing them. +Behind the scenes we migrated the UI and app into Kotlin and Jetpack Compose for a smoother and more modern experience. + +What's different from other shared budget managers? + +You can keep your project local or make it synchronize with Nextcloud Cospend projects. +This means you can choose where your data is going and preserve your privacy. + +# Features + +* Manage projects, members and bills (add/remove/edit) +* Search bills (by payer, name, amount, date) +* Generate and share project statistics and settlement plan +* Dark theme and customizable main app color +* Share/import projects with link/QRCode +* Connect to a Nextcloud account to automatically add projects +* Background sync service with notifications on bills events +* Multi-lingual user-interface (translated on Crowdin: https://crowdin.com/project/cowspent) + +# Requirements + +If you want to host a project in Nextcloud Cospend : + +* Nextcloud instance running with Cospend app installed + +If you want to be able to create remote projects from Cowspent : enable public project creation on your Nextcloud Cospend instance. + +# Alternatives + +MoneyBuster which is FOSS but has older UI and was missing some QoL features. +Tricount, Cospender, Splitwise etc... which are not FOSS and force you to give away your private data. diff --git a/metadata/en-US/images/icon.webp b/metadata/en-US/images/icon.webp new file mode 100644 index 0000000..975d76a Binary files /dev/null and b/metadata/en-US/images/icon.webp differ diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..5a6ad02 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..121f970 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/2.png differ diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..68b9670 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/3.png differ diff --git a/metadata/en-US/images/phoneScreenshots/4.png b/metadata/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..3fd85ae Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/4.png differ diff --git a/metadata/en-US/images/phoneScreenshots/5.png b/metadata/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..5ebe2a8 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/5.png differ diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt new file mode 100644 index 0000000..81133e3 --- /dev/null +++ b/metadata/en-US/short_description.txt @@ -0,0 +1 @@ +Multi-project shared budget manager able to sync with self hosted FOSS services diff --git a/metadata/en-US/title.txt b/metadata/en-US/title.txt new file mode 100644 index 0000000..a53fae4 --- /dev/null +++ b/metadata/en-US/title.txt @@ -0,0 +1 @@ +Cowspent diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..df9450c --- /dev/null +++ b/settings.gradle @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { url = 'https://jitpack.io' } + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = 'https://jitpack.io' } + } +} + +rootProject.name = "Cowspent" +include ':app'