init
Some checks failed
CI-Android APK / build (push) Failing after 4m27s
Some checks failed
CI-Android APK / build (push) Failing after 4m27s
This commit is contained in:
68
.github/workflows/build.yml
vendored
Normal file
68
.github/workflows/build.yml
vendored
Normal 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
16
.gitignore
vendored
Normal 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
674
LICENSE
Normal 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
12
PRIVACY.md
Normal 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
107
README.md
Normal 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
111
app/build.gradle
Normal 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
17
app/proguard-rules.pro
vendored
Normal 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 *;
|
||||
#}
|
||||
126
app/src/main/AndroidManifest.xml
Normal file
126
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
350
app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt
Normal file
350
app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt
Normal 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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 } ?: ""
|
||||
}
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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 = {}
|
||||
) }
|
||||
}
|
||||
5
app/src/main/java/net/helcel/cowspent/model/Category.kt
Normal file
5
app/src/main/java/net/helcel/cowspent/model/Category.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package net.helcel.cowspent.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
class Category(val memberName: String?, val memberId: Long?) : Serializable
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.helcel.cowspent.model
|
||||
|
||||
class CreditDebt(var memberId: Long, var balance: Double)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
121
app/src/main/java/net/helcel/cowspent/model/DBBill.kt
Normal file
121
app/src/main/java/net/helcel/cowspent/model/DBBill.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
14
app/src/main/java/net/helcel/cowspent/model/DBBillOwer.kt
Normal file
14
app/src/main/java/net/helcel/cowspent/model/DBBillOwer.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/net/helcel/cowspent/model/DBCategory.kt
Normal file
17
app/src/main/java/net/helcel/cowspent/model/DBCategory.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/net/helcel/cowspent/model/DBCurrency.kt
Normal file
17
app/src/main/java/net/helcel/cowspent/model/DBCurrency.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/net/helcel/cowspent/model/DBMember.kt
Normal file
23
app/src/main/java/net/helcel/cowspent/model/DBMember.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt
Normal file
17
app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/net/helcel/cowspent/model/DBProject.kt
Normal file
75
app/src/main/java/net/helcel/cowspent/model/DBProject.kt
Normal 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
|
||||
}
|
||||
}
|
||||
26
app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt
Normal file
26
app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
5
app/src/main/java/net/helcel/cowspent/model/Item.kt
Normal file
5
app/src/main/java/net/helcel/cowspent/model/Item.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package net.helcel.cowspent.model
|
||||
|
||||
interface Item {
|
||||
fun isSection(): Boolean
|
||||
}
|
||||
20
app/src/main/java/net/helcel/cowspent/model/ProjectType.kt
Normal file
20
app/src/main/java/net/helcel/cowspent/model/ProjectType.kt
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package net.helcel.cowspent.model
|
||||
|
||||
class SectionItem(var title: String) : Item {
|
||||
override fun isSection(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package net.helcel.cowspent.model
|
||||
|
||||
class Transaction(var owerMemberId: Long, var receiverMemberId: Long, var amount: Double)
|
||||
3
app/src/main/java/net/helcel/cowspent/model/UserItem.kt
Normal file
3
app/src/main/java/net/helcel/cowspent/model/UserItem.kt
Normal file
@@ -0,0 +1,3 @@
|
||||
package net.helcel.cowspent.model
|
||||
|
||||
class UserItem(var id: Long, var name: String)
|
||||
@@ -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)
|
||||
@@ -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
119
app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt
Normal file
119
app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
58
app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt
Normal file
58
app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/src/main/java/net/helcel/cowspent/util/BillParser.kt
Normal file
68
app/src/main/java/net/helcel/cowspent/util/BillParser.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt
Normal file
37
app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt
Normal 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/net/helcel/cowspent/util/ColorUtils.kt
Normal file
95
app/src/main/java/net/helcel/cowspent/util/ColorUtils.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
121
app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt
Normal file
121
app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/net/helcel/cowspent/util/Cowspent.kt
Normal file
31
app/src/main/java/net/helcel/cowspent/util/Cowspent.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt
Normal file
88
app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/net/helcel/cowspent/util/ICallback.kt
Normal file
10
app/src/main/java/net/helcel/cowspent/util/ICallback.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package net.helcel.cowspent.util
|
||||
|
||||
/**
|
||||
* Callback
|
||||
*/
|
||||
interface ICallback {
|
||||
fun onFinish()
|
||||
fun onFinish(result: String, message: String)
|
||||
fun onScheduled()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.helcel.cowspent.util
|
||||
|
||||
/**
|
||||
* Callback
|
||||
*/
|
||||
interface IProjectCreationCallback {
|
||||
fun onFinish(result: String, message: String, usePrivateApi: Boolean)
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
302
app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt
Normal file
302
app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
776
app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt
Normal file
776
app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt
Normal 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
|
||||
}
|
||||
}
|
||||
338
app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt
Normal file
338
app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt
Normal 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
21
app/src/main/java/net/helcel/cowspent/util/evalMath.kt
Normal file
21
app/src/main/java/net/helcel/cowspent/util/evalMath.kt
Normal 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
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
11
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
61
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
61
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
52
app/src/main/res/drawable/test.xml
Normal file
52
app/src/main/res/drawable/test.xml
Normal 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
Reference in New Issue
Block a user