init
Some checks failed
CI-Android APK / build (push) Failing after 4m27s

This commit is contained in:
soraefir
2026-05-23 15:09:28 +02:00
commit 5ee2041769
129 changed files with 19835 additions and 0 deletions

68
.github/workflows/build.yml vendored Normal file
View File

@@ -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

16
.gitignore vendored Normal file
View File

@@ -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

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
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 <http://www.gnu.org/licenses/>.
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
<http://www.gnu.org/licenses/>.
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
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

12
PRIVACY.md Normal file
View File

@@ -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.

107
README.md Normal file
View File

@@ -0,0 +1,107 @@
<!--suppress ALL -->
<div align="center">
<h1>Cowspent</h1>
<img width="100px" src="./metadata/en-US/images/icon.webp" alt="Logo">
<p>
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: !
</p>
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
<br>
<img src="https://forthebadge.com/images/badges/built-for-android.svg" alt="Built for Android">
<img src="https://forthebadge.com/images/badges/built-with-love.svg" alt="Built with love">
<br>
<a href="https://github.com/helcel-net/cowspent/actions/workflows/build.yml">
<img src="https://github.com/helcel-net/cowspent/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
</a>
</div>
## 🌄 Screenshots
<div align="center">
<table>
<tr>
<td style="width: 33%; height: 100px;"><img src="./metadata/en-US/images/phoneScreenshots/launcher.jpg" alt="Launcher" style="width: 100%; height: 100%;"></td>
<td style="width: 33%; height: 100px;"><img src="./metadata/en-US/images/phoneScreenshots/view.jpg" alt="View" style="width: 100%; height: 100%;"></td>
<td style="width: 33%; height: 100px;"><img src="./metadata/en-US/images/phoneScreenshots/edit.jpg" alt="Edit" style="width: 100%; height: 100%;"></td>
</tr>
</table>
</div>
## ⭐ 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
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.cowspent">
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
</a>
<a href="https://github.com/helcel-net/cowspent/releases/latest">
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a>
</div>
## ⚙️ 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 <https://creativecommons.org/licenses/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 <https://www.gnu.org/licenses/>.
```

111
app/build.gradle Normal file
View File

@@ -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'
}

17
app/proguard-rules.pro vendored Normal file
View File

@@ -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 *;
#}

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="preferExternal">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
<application
android:name="net.helcel.cowspent.util.Cowspent"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity" />
<activity
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
<meta-data
android:name="android.app.default_searchable"
android:value="net.helcel.cowspent.android.main.BillsListViewActivity" />
</activity>
<activity
android:name="net.helcel.cowspent.android.account.AccountActivity"
android:label="@string/settings_server_settings"
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
android:windowSoftInputMode="stateVisible"
android:launchMode="standard"
/>
<activity
android:name="net.helcel.cowspent.android.settings.PreferencesActivity"
android:label="@string/action_settings"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden" />
<activity
android:name="net.helcel.cowspent.android.helper.QrCodeScannerActivity"
android:label="@string/action_scan_qrcode"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
/>
<activity
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
android:label="@string/simple_add_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
android:launchMode="standard"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cospend" />
<data android:scheme="cowspent" />
<data android:host="*" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cospend+http" />
<data android:scheme="cowspent+http" />
<data android:host="*" />
<data android:pathPrefix="/" />
</intent-filter>
</activity>
<activity
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
android:label="@string/simple_edit_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
android:launchMode="standard">
</activity>
<activity
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
android:label="@string/simple_edit_bill"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
android:launchMode="standard">
</activity>
<activity
android:name="net.helcel.cowspent.android.about.AboutActivity"
android:label="@string/simple_about"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" />
<activity
android:name="net.helcel.cowspent.android.statistics.ProjectStatisticsActivity"
android:label="@string/statistic_title"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name="net.helcel.cowspent.android.bill_label.LabelBillsActivity"
android:label="@string/label_bills_title"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -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() }
)
}
}
}
}

View File

@@ -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 = {})
}
}

View File

@@ -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<String, String>()
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 = ""
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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())
}

View File

@@ -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."
)
}
}

View File

@@ -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<Long, Double>? = null
if (groupedBillIds != null && groupedBillIds.isNotEmpty()) {
val sourceBills: List<DBBill> = 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<Long, Double>()
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<Long>()
if (bill.id != 0L) billsToPool.add(bill.id)
groupedBillIds?.forEach { if (it != bill.id) billsToPool.add(it) }
val processedBillIds = mutableSetOf<Long>()
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"
}
}

View File

@@ -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<DBCategory>,
paymentModes: List<DBPaymentMode>,
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<DBCategory>,
paymentModes: List<DBPaymentMode>,
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 = {}
)
}
}

View File

@@ -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<List<DBCurrency>>(emptyList())
var mainCurrencyName by mutableStateOf("")
var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>()
var isCustomSplit by mutableStateOf(false)
var owersCustomSplit = mutableStateMapOf<Long, String>()
var dialogState by mutableStateOf<DialogState?>(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<Long> {
return owersSelection.filter { it.value }.keys.toList()
}
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = 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<DBMember>, customSplits: Map<Long, Double>? = 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
}
}
}

View File

@@ -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<A, B, C, D>(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)
}
}
}
}

View File

@@ -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<DBMember>,
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<DBMember>, 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 = {}
)
}
}

View File

@@ -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<List<DBBill>>(emptyList())
internal set
var currentBillIndex by mutableIntStateOf(0)
private set
var categories by mutableStateOf<List<DBCategory>>(emptyList())
internal set
var suggestedCategories by mutableStateOf<List<DBCategory>>(emptyList())
private set
internal var categoriesMap: Map<Long, DBCategory> = emptyMap()
internal var allCategorizedBills: List<DBBill> = 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()
}
}

View File

@@ -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"
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<List<DBCurrency>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null)
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = 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()
}
}

View File

@@ -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<DBProject>,
members: List<DBMember>,
memberBalances: Map<Long, Double> = 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<DBProject> { 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 = {}
)
}
}

View File

@@ -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<CharSequence>? = null,
val itemIcons: List<ImageVector>? = 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<CharSequence>? = null,
itemIcons: List<ImageVector>? = 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<out CharSequence>? = null,
itemIcons: Array<out ImageVector>? = 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"
)
}
}

View File

@@ -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<Float>,
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 = {})
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)}"
}

View File

@@ -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
)
}
}
}

View File

@@ -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<String>, 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
)
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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))
}
}

View File

@@ -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()
}
}

View File

@@ -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<DBBill>,
membersMap: Map<Long, DBMember>,
sdf: SimpleDateFormat,
context: Context
): List<Item> {
val groupedBillsList = mutableListOf<DBBill>()
val groups = bills.groupBy { "${it.what}|${it.date}|${it.time}|${it.payerId}" }
val seenGroups = mutableSetOf<String>()
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<Item> = 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<Transaction>,
memberIdToName: Map<Long, String>
) {
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<Transaction>,
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)
}
}

View File

@@ -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<Long, Double>())
}
}
val members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id)
val balances = HashMap<Long, Double>()
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<DBBill> = 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()
}
}
}
}
}
}
}
}

View File

@@ -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<List<DBProject>>(emptyList())
var members by mutableStateOf<List<DBMember>>(emptyList())
var memberBalances by mutableStateOf<Map<Long, Double>>(emptyMap())
var selectedProjectId by mutableLongStateOf(0L)
var selectedMemberId by mutableStateOf<Long?>(null)
var bills by mutableStateOf<List<Item>>(emptyList())
var isRefreshing by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var title by mutableStateOf("")
var accountName by mutableStateOf("")
var userAvatar by mutableStateOf<android.graphics.Bitmap?>(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<DialogState?>(null)
var showProjectOptionsDialogByProjectId by mutableStateOf<Long?>(null)
var showSettlementDialogByProjectId by mutableStateOf<Long?>(null)
var showStatisticsDialogByProjectId by mutableStateOf<Long?>(null)
var showMemberManagementDialogByProjectId by mutableStateOf<Long?>(null)
var showAddMemberDialogByProjectId by mutableStateOf<Long?>(null)
var showEditMemberDialogByProjectId by mutableStateOf<Long?>(null)
var showShareDialogByProjectId by mutableStateOf<Long?>(null)
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = null,
itemIcons: List<ImageVector>? = 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
}
}

View File

@@ -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"
}

View File

@@ -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<String, Int>()
val paymentModes = mutableListOf<DBPaymentMode>()
val categories = mutableListOf<DBCategory>()
val currencies = mutableListOf<DBCurrency>()
val bills = mutableListOf<DBBill>()
val membersActive = mutableMapOf<String, Boolean>()
val membersWeight = mutableMapOf<String, Double>()
val billRemoteIdToPayerName = mutableMapOf<Long, String>()
val billRemoteIdToOwerStr = mutableMapOf<Long, String>()
var nextLine: Array<String>?
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<String, Long>()
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}")
}
}
}

View File

@@ -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<ProjectOption>()
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
)
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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"
}
}

View File

@@ -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 = {}
)
}

View File

@@ -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<List<DBAccountProject>>(emptyList())
var isCreatingRemoteProject by mutableStateOf(false)
var errorDialogMessage by mutableStateOf<String?>(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
}
}
}

View File

@@ -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"
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<DialogState?>(null)
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = 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 } ?: ""
}
}

View File

@@ -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 = {})
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<DBMember>,
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 = {}
)
}
}

View File

@@ -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<Transaction>) -> Unit,
onShare: (List<Transaction>, Map<Long, String>) -> 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<Long, Double>()
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<Transaction>,
userList: List<UserItem>,
selectedMemberId: Long,
memberIdToName: Map<Long, String>,
onMemberSelected: (Long) -> Unit,
onSettleBills: (List<Transaction>) -> Unit,
onShare: (List<Transaction>, Map<Long, String>) -> 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<UserItem>,
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<Transaction>,
memberIdToName: Map<Long, String>,
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<Long, String>
) {
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<Transaction>,
memberIdToName: Map<Long, String>,
onShare: (List<Transaction>, Map<Long, String>) -> Unit,
onSettleBills: (List<Transaction>) -> 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 = {}
)
}
}

View File

@@ -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 }
)
}
}
}
}

View File

@@ -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<String, String>,
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 = {}
)
}
}

View File

@@ -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<DBMember>,
allBills: List<DBBill>,
customCategories: List<DBCategory>,
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<Long, Double>()
val catMap = mutableMapOf<Int, Double>()
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 = {}
)
}
}

View File

@@ -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<DBMember>,
allBills: List<DBBill>,
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<Long>()
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<Long, List<Double>>()
allMembers.forEach { member ->
val spending = mutableListOf<Double>()
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..<end && it.payerId == member.id }
.sumOf { it.amount }
spending.add(amount)
}
memberSpending[member.id] = spending
}
// EMA Trend Line calculation
val alpha = 0.7
var lastEma = 0.0
val trend = periods.indices.map { i ->
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 = {}
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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))
}
)
}

View File

@@ -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<StatisticsData?>(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<DBMember>,
val bills: List<DBBill>,
val categories: List<DBCategory>,
val paymentModes: List<DBPaymentMode>
)

View File

@@ -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<DBMember>,
allBills: List<DBBill>,
customCategories: List<DBCategory>,
customPaymentModes: List<DBPaymentMode>,
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<String?>(null) }
var dateMax by remember { mutableStateOf<String?>(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<Triple<Int, String, String>>()
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<Triple<Int, String, String>>()
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<Long, Int>()
val membersBalance = HashMap<Long, Double>()
val membersPaid = HashMap<Long, Double>()
val membersSpent = HashMap<Long, Double>()
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<MemberStat>, 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 = {}
) }
}

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model
import java.io.Serializable
class Category(val memberName: String?, val memberId: Long?) : Serializable

View File

@@ -0,0 +1,3 @@
package net.helcel.cowspent.model
class CreditDebt(var memberId: Long, var balance: Double)

View File

@@ -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"
}
}

View File

@@ -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<DBBillOwer> = ArrayList()
val billOwersIds: List<Long>
get() {
val result: MutableList<Long> = 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<String, Int> = object : HashMap<String, Int>() {
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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,26 @@
package net.helcel.cowspent.model
import java.io.Serializable
class GroupedBill(
val sourceBills: List<DBBill>
) : 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 }
}
}

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model
interface Item {
fun isSection(): Boolean
}

View File

@@ -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<String, ProjectType> = HashMap()
init {
for (type in entries) {
(reverseMap as MutableMap)[type.id] = type
}
}
@JvmStatic
fun getTypeById(id: String?): ProjectType? {
return reverseMap[id]
}
}
}

View File

@@ -0,0 +1,8 @@
package net.helcel.cowspent.model
class SectionItem(var title: String) : Item {
override fun isSection(): Boolean {
return true
}
}

View File

@@ -0,0 +1,3 @@
package net.helcel.cowspent.model
class Transaction(var owerMemberId: Long, var receiverMemberId: Long, var amount: Double)

View File

@@ -0,0 +1,3 @@
package net.helcel.cowspent.model
class UserItem(var id: Long, var name: String)

View File

@@ -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)

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model.parsed
import java.time.LocalDateTime
data class CroatianBillQrCode(val date: LocalDateTime?, val amount: Double)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}
}
}
}
}
)
}
}

View File

@@ -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<DBBill>,
membersMap: Map<Long, DBMember>,
categoriesMap: Map<Long, DBCategory>,
paymentModesMap: Map<Long, DBPaymentMode>
) {
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"
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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<DBCategory> {
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<DBPaymentMode> {
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")
)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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<Long, DBMember> = 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<DBBillOwer> = 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"
}
}
}

View File

@@ -0,0 +1,10 @@
package net.helcel.cowspent.util
/**
* Callback
*/
interface ICallback {
fun onFinish()
fun onFinish(result: String, message: String)
fun onScheduled()
}

View File

@@ -0,0 +1,9 @@
package net.helcel.cowspent.util
/**
* Callback
*/
interface IProjectCreationCallback {
fun onFinish(result: String, message: String, usePrivateApi: Boolean)
}

View File

@@ -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)
}

View File

@@ -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<QueryParam>?,
isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
val result = StringBuilder()
val headers: MutableMap<String, List<String>> = HashMap()
if (isOCSRequest) {
val acceptHeader: MutableList<String> = 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<QueryParam>?
): 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"
}
}

View File

@@ -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<DBMember> {
return getMembersFromJSON(getResponseObjectData(), projId)
}
@Throws(JSONException::class)
fun getCategories(projId: Long): List<DBCategory> {
return getCategoriesFromJSON(getResponseObjectData(), projId)
}
@Throws(JSONException::class)
fun getPaymentModes(projId: Long): List<DBPaymentMode> {
return getPaymentModesFromJSON(getResponseObjectData(), projId)
}
@Throws(JSONException::class)
fun getCurrencies(projId: Long): List<DBCurrency> {
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<Long, Long>): List<DBBill> {
return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId)
}
@Throws(JSONException::class)
fun getBillsIHM(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> {
return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId)
}
@get:Throws(JSONException::class)
val allBillIds: List<Long>
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<DBMember> {
return getMembersFromJSONArray(getResponseArrayData(), projId)
}
}
class AccountProjectsResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getAccountProjects(ncUrl: String): List<DBAccountProject> {
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<DBMember> {
val members: MutableList<DBMember> = 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<DBCategory> {
val categories: MutableList<DBCategory> = 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<DBPaymentMode> {
val paymentModes: MutableList<DBPaymentMode> = 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<DBCurrency> {
val currencies: MutableList<DBCurrency> = 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<DBMember> {
val members: MutableList<DBMember> = 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<Long> {
val billIds: MutableList<Long> = 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<Long, Long>
): List<DBBill> {
val bills: MutableList<DBBill> = 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<Long, Long>
): List<DBBill> {
val bills: List<DBBill>
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<Long, Long>
): 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<Long, Long>
): List<DBBillOwer> {
val billOwers: MutableList<DBBillOwer> = 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<DBAccountProject> {
val accountProjects: MutableList<DBAccountProject> = 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
}
}

View File

@@ -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<Long, Int>,
membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>,
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<DBMember>,
dbBills: List<DBBill>,
membersNbBills: MutableMap<Long, Int>,
membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int,
dateMin: String?, dateMax: String?
): Int {
val nbBillsTotal = 0
val membersWeight: MutableMap<Long, Double> = 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<DBMember>, membersBalance: Map<Long, Double>,
centerOnMemberId: Long
): List<Transaction> {
return if (centerOnMemberId == SETTLE_OPTIMAL) {
settleBillsOptimal(members, membersBalance)
} else {
val results: MutableList<Transaction> = 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<DBMember>, membersBalance: Map<Long, Double>): List<Transaction> {
val crediters: MutableList<CreditDebt> = ArrayList()
val debiters: MutableList<CreditDebt> = 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<CreditDebt>,
debiters: MutableList<CreditDebt>,
resultsParam: MutableList<Transaction>?
): List<Transaction> {
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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<!-- Matches #0F172A Base -->
<path
android:pathData="M0,0h100v100h-100z"
android:fillColor="#0F172A"/>
</vector>

View File

@@ -0,0 +1,61 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group android:translateY="0.33333334">
<group android:translateY="-0.33333334">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</group>
</group>
</group>
</vector>

View File

@@ -0,0 +1,52 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More