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