Initial commit: NCal, a native Nextcloud CalDAV client for Android
One app for both events and tasks, talking directly to Nextcloud's CalDAV endpoint - no DAVx5, no Google Play Services, no third-party services. Encrypted at rest (SQLCipher DB, EncryptedSharedPreferences, Keystore-backed widget state), HTTPS-only networking, home screen widgets, reminders, subtasks, and full VTODO field support (status/priority/%complete/location/url).
This commit is contained in:
commit
75f5e6024b
|
|
@ -0,0 +1,6 @@
|
|||
# Claude Code session-local config - not part of the project
|
||||
.claude/
|
||||
|
||||
# Release signing - never commit these (see ncal/.gitignore for the enforced copy)
|
||||
/ncal/keystore/
|
||||
/ncal/keystore.properties
|
||||
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://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 <https://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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# NCal
|
||||
|
||||
A native Android app that talks **directly to Nextcloud's CalDAV endpoint** — no
|
||||
DAVx⁵, no separate tasks app, no Android Calendar/Tasks Provider in the middle. One
|
||||
app, full CRUD, for both events and tasks, plus subtasks, reminders, and home screen
|
||||
widgets — built to run on phones with **no Google Play Services at all**.
|
||||
|
||||
The Android project lives in [`ncal/`](ncal/); open that folder in Android Studio.
|
||||
|
||||
## Why this exists
|
||||
|
||||
No mainstream Android app natively renders both `VEVENT` (events) and `VTODO` (tasks)
|
||||
from CalDAV in one UI. DAVx⁵ + Etar + Tasks.org gets you three apps for one job. This
|
||||
is the fourth option: one app, one account, one sync engine.
|
||||
|
||||
## Privacy & security
|
||||
|
||||
- **No Google Play Services, anywhere.** Sync, reminders, and the home screen widgets
|
||||
all use plain AOSP/AndroidX APIs (`AlarmManager`, `NotificationManager`,
|
||||
`androidx.glance`, `androidx.work`) — every one of these is a core platform
|
||||
capability, not a Google service, so the app runs unmodified on GrapheneOS,
|
||||
LineageOS, or any other de-googled ROM.
|
||||
- **No analytics, crash reporting, or ad SDKs.** Check `ncal/app/build.gradle.kts` —
|
||||
every dependency is either AndroidX/Jetpack, OkHttp, or `biweekly` (iCalendar
|
||||
parsing). Nothing phones home to a vendor.
|
||||
- **No third-party server.** The only network calls in the app go to whatever
|
||||
Nextcloud server URL *you* enter at login (`data/network/CalDavClient.kt`). There is
|
||||
no hardcoded third-party endpoint anywhere in the source.
|
||||
- **Everything is encrypted at rest.** Server URL, username, and app password live in
|
||||
`EncryptedSharedPreferences` (`data/prefs/SecurePrefs.kt`, AES-256-GCM/SIV via an
|
||||
Android Keystore-backed `MasterKey`). The local Room cache itself is an
|
||||
SQLCipher-encrypted database (`ncal_encrypted.db`), not plain SQLite — nothing the
|
||||
app writes to disk, including the widgets' own tiny bit of navigation state, is ever
|
||||
unencrypted. `android:allowBackup="false"` also keeps Android's cloud backup from
|
||||
ever exporting any of it off-device.
|
||||
- **HTTPS only.** `usesCleartextTraffic="false"` blocks plain HTTP at the OS network
|
||||
layer, and the login screen always normalizes whatever you type to `https://`.
|
||||
- **Local-first.** The calendar/task cache is a local, encrypted database. It exists
|
||||
purely so the UI renders instantly and works offline; the server is always the
|
||||
source of truth on next sync.
|
||||
|
||||
## Features
|
||||
|
||||
- **Agenda** — unified, day-grouped list of events *and* tasks. Syncs automatically
|
||||
every time the app is opened, plus pull to refresh any time.
|
||||
- **Month view** — a full calendar grid with events rendered as spanning colored
|
||||
pills and tasks as dot + text, exactly mirroring what's on the home screen widget
|
||||
(they share the same layout algorithm). Tap a day to see its agenda below the grid.
|
||||
- **Tasks** — dedicated checklist view. Tasks render as an indented tree under their
|
||||
parent, not a flat list.
|
||||
- **Subtasks** — assign any task a parent task (stored as the standard iCalendar
|
||||
`RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other
|
||||
CalDAV clients). The parent picker excludes the task itself and its own descendants
|
||||
to prevent cycles.
|
||||
- **Reminders** — add one or more reminders to any event or task (at the scheduled
|
||||
time, or a preset offset before it: 5 min up to 1 week). Reminders fire as real
|
||||
system notifications via `AlarmManager` exact alarms — not a best-effort background
|
||||
job — and survive reboots and app updates. Tapping a reminder notification opens
|
||||
the app directly to that item.
|
||||
- **Task status, priority, % complete, location, and URL** — full VTODO fields, not
|
||||
just a completed checkbox: a proper status (Not started / In progress / Completed /
|
||||
Cancelled), an iCal `PRIORITY` 1-9 picker color-coded 1-4 red/5 yellow/6-9 blue
|
||||
(surfaced as the task's leading dot everywhere it's shown — Tasks list, Month view,
|
||||
both widgets), a 0-100% completion slider, and location/URL fields shared with
|
||||
events.
|
||||
- **Home screen widgets** (see below).
|
||||
- **Calendars** — pick which calendars/task-lists sync, mirroring what you'd see in
|
||||
Nextcloud's own sidebar.
|
||||
- **Recurring events and tasks** — `RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with
|
||||
`INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its
|
||||
next upcoming occurrence. See "Known limitations" below for what this doesn't cover.
|
||||
|
||||
## Home screen widgets
|
||||
|
||||
Two widgets, built with Jetpack Glance (`androidx.glance:glance-appwidget`), which
|
||||
compiles down to plain `RemoteViews`/`AppWidgetProvider` — no Google dependency:
|
||||
|
||||
- **Month grid widget** ("Calendar Month" in the widget picker) — the exact same
|
||||
month view as the in-app Month screen (shared layout code in
|
||||
`util/MonthGridLayout.kt`), with ‹ › month navigation and tap-to-open on any event
|
||||
or task. Resizable, and fonts/day-circle size/event-lane count all scale up when
|
||||
you make the widget bigger instead of leaving blank space.
|
||||
- **Next Tasks widget** ("Tasks" in the widget picker) — a scrollable list of
|
||||
incomplete tasks grouped and ordered by due date, with a tap-to-complete circle on
|
||||
each row (color-coded by priority) and tap-to-open on the row itself.
|
||||
|
||||
Both widgets refresh automatically whenever you sync, save, or delete something in
|
||||
the app — no manual refresh needed. Note this means a widget's content is only as
|
||||
fresh as the last time the app itself synced (on open, on manual pull-to-refresh, or
|
||||
after an edit) — the widgets read from the local cache on their own ~30-minute OS
|
||||
redraw timer, they don't independently hit the network.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Kotlin + Jetpack Compose (Material 3)**, MVVM, single-activity, no DI framework
|
||||
(`ui/ViewModelFactory.kt` is a small hand-rolled factory instead).
|
||||
- **`CalDavClient`** (`data/network/`) — hand-rolled CalDAV client over OkHttp. Speaks
|
||||
`PROPFIND` (discover calendars/task-lists), `REPORT` (`calendar-query` to fetch
|
||||
`VEVENT`/`VTODO`), and `PUT`/`DELETE` with `If-Match`/`If-None-Match` for safe,
|
||||
conflict-aware writes. Every successful `PUT` captures the server's returned `ETag`
|
||||
so the next edit doesn't race against a stale local copy.
|
||||
- **`biweekly`** — parses/generates the actual iCalendar (RFC 5545) payloads,
|
||||
including `VALARM` (reminders) and `RELATED-TO` (subtasks).
|
||||
- **Room, on an SQLCipher-encrypted database** (`data/db/`) — local cache so every
|
||||
screen (and both widgets) render instantly and survive being offline, without ever
|
||||
touching disk unencrypted.
|
||||
- **`NextcloudRepository`** — single source of truth gluing the network client, the
|
||||
DB, reminder scheduling, and widget refresh together. Every screen reads from Room
|
||||
`Flow`s; every write goes through `save()`/`delete()`, which do the network call
|
||||
first, then update the cache, reminders, and widgets.
|
||||
- **`ReminderScheduler`** (`notifications/`) — schedules one exact `AlarmManager`
|
||||
alarm per item, always for its *next* upcoming reminder offset; the receiver
|
||||
re-arms the following offset each time it fires, so there's never more than one
|
||||
pending alarm per item to track.
|
||||
- **`MonthGridWidget` / `NextTasksWidget`** (`widget/`) — Glance widgets that read
|
||||
straight from the same repository the app uses.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Create a Nextcloud app password** — not your real login password.
|
||||
Settings → Security → Devices & sessions → Create new app password.
|
||||
2. **Open `ncal/` in Android Studio** (Koala/2024.1 or newer). Let it sync Gradle.
|
||||
3. Run on a device or emulator (min SDK 26 / Android 8+).
|
||||
4. On first launch, enter your server URL, username, and the app password.
|
||||
5. Go to the **Calendars** screen (gear icon) and confirm which calendars/task-lists
|
||||
are enabled.
|
||||
6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month"
|
||||
and/or "Tasks".
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Editing an existing recurring item drops its `RRULE`.** Saving rebuilds the
|
||||
`.ics` from the edit form's fields, so an edited recurring item becomes a one-off.
|
||||
Viewing/completing/deleting a recurring item is safe; editing its fields is not,
|
||||
if you want the series to survive the save. `CalendarItem.rawIcs` retains the
|
||||
original document for exactly this reason — wiring `toIcs()` to mutate the parsed
|
||||
original in place is the natural fix if this turns out to matter in practice.
|
||||
- **No recurrence-editing UI** — no picker to *set* a recurrence rule on a new item.
|
||||
- **Subtask tree is app-side only.** The Month view and both widgets show tasks
|
||||
flat/chronological, not nested — only the in-app Tasks screen renders the parent/
|
||||
child tree.
|
||||
- **No background sync while the app is closed.** Sync happens on app open, manual
|
||||
pull-to-refresh, or after an edit — there's no periodic `WorkManager` job calling
|
||||
`repository.fullSync()` yet, so a widget can go stale if you never open the app.
|
||||
- **Last-write-wins conflict handling.** A genuinely stale write fails loudly (a
|
||||
`CalDavException` with the HTTP code) rather than silently clobbering a change made
|
||||
elsewhere — but there's no merge UI, just an error surfaced to the edit screen.
|
||||
|
||||
## Where to look first if you want to extend it
|
||||
|
||||
- `data/network/CalDavClient.kt` — all server communication.
|
||||
- `util/IcsMapper.kt` — the iCalendar ⇄ app-model translation layer (events, tasks,
|
||||
subtasks, reminders).
|
||||
- `util/MonthGridLayout.kt` — the month-grid layout algorithm shared by the in-app
|
||||
Month screen and the month widget.
|
||||
- `data/repository/NextcloudRepository.kt` — the sync/cache/reminder/widget
|
||||
orchestration.
|
||||
- `notifications/ReminderScheduler.kt` — reminder alarm scheduling.
|
||||
- `widget/` — the two Glance home screen widgets.
|
||||
|
||||
## Building a signed release
|
||||
|
||||
`ncal/app/build.gradle.kts` reads signing credentials from a git-ignored
|
||||
`ncal/keystore.properties` (`storePassword`, `keyPassword`, `keyAlias`, `storeFile`) —
|
||||
generate your own keystore and fill that file in; nothing under `ncal/keystore/` or
|
||||
`ncal/keystore.properties` is tracked by git, and no signing key ships in this repo.
|
||||
Without that file present, `:app:assembleRelease` still produces an **unsigned** APK.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 — see [`LICENSE`](LICENSE).
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/build
|
||||
|
||||
# Release signing - never commit these
|
||||
/keystore/
|
||||
/keystore.properties
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.devtools.ksp")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties().apply {
|
||||
if (keystorePropertiesFile.exists()) load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.homelab.ncal"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.homelab.ncal"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
|
||||
// Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// Networking (CalDAV/WebDAV over HTTP)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// iCalendar (RFC 5545) parsing/generation for VEVENT/VTODO
|
||||
implementation("net.sf.biweekly:biweekly:0.6.8")
|
||||
|
||||
// Encrypted storage for server credentials
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
// WorkManager - required transitively by Glance's own widget-scheduling machinery;
|
||||
// declared explicitly to pin a current version rather than Glance's older transitive default.
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
||||
// Home screen widget (month calendar)
|
||||
implementation("androidx.glance:glance-appwidget:1.1.1")
|
||||
|
||||
// Encrypts the local Room cache at rest (calendar/task content), same as credentials already are
|
||||
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
-keep class biweekly.** { *; }
|
||||
-dontwarn biweekly.**
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".NcalApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.NCal"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.NCal"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".notifications.ReminderReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".notifications.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.MonthGridWidgetReceiver"
|
||||
android:exported="false"
|
||||
android:label="@string/month_grid_widget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/month_grid_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.NextTasksWidgetReceiver"
|
||||
android:exported="false"
|
||||
android:label="@string/next_tasks_widget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/next_tasks_widget_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.homelab.ncal
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.homelab.ncal.ui.nav.NcalNavGraph
|
||||
import com.homelab.ncal.ui.theme.NCalTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var pendingDeepLink by mutableStateOf<Pair<String, String>?>(null)
|
||||
|
||||
private val requestNotificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
pendingDeepLink = deepLinkFrom(intent)
|
||||
|
||||
setContent {
|
||||
NCalTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NcalNavGraph(
|
||||
app = application as NcalApplication,
|
||||
pendingDeepLink = pendingDeepLink,
|
||||
onDeepLinkConsumed = { pendingDeepLink = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pendingDeepLink = deepLinkFrom(intent)
|
||||
}
|
||||
|
||||
private fun deepLinkFrom(intent: Intent?): Pair<String, String>? {
|
||||
val type = intent?.getStringExtra(EXTRA_ITEM_TYPE) ?: return null
|
||||
val href = intent.getStringExtra(EXTRA_ITEM_HREF) ?: return null
|
||||
return type to href
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ITEM_TYPE = "item_type"
|
||||
const val EXTRA_ITEM_HREF = "item_href"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.homelab.ncal
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import com.homelab.ncal.notifications.CHANNEL_ID
|
||||
import com.homelab.ncal.notifications.ReminderScheduler
|
||||
import com.homelab.ncal.widget.MonthGridWidget
|
||||
import com.homelab.ncal.widget.NextTasksWidget
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
|
||||
class NcalApplication : Application() {
|
||||
lateinit var repository: NextcloudRepository
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
SQLiteDatabase.loadLibs(this)
|
||||
deleteObsoletePlaintextDatabase()
|
||||
repository = NextcloudRepository(this)
|
||||
createReminderNotificationChannel()
|
||||
|
||||
if (repository.isLoggedIn) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems())
|
||||
MonthGridWidget().updateAll(this@NcalApplication)
|
||||
NextTasksWidget().updateAll(this@NcalApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The DB moved to an encrypted file (`ncal_encrypted.db`) - the old plaintext copy would
|
||||
* otherwise sit on disk unencrypted and unused, which defeats the point of switching. */
|
||||
private fun deleteObsoletePlaintextDatabase() {
|
||||
deleteDatabase("ncal.db")
|
||||
}
|
||||
|
||||
private fun createReminderNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Reminders",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Event and task reminders"
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.homelab.ncal.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import net.sqlcipher.database.SupportFactory
|
||||
|
||||
@Database(
|
||||
entities = [CollectionEntity::class, ItemEntity::class],
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
abstract fun itemDao(): ItemDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var instance: AppDatabase? = null
|
||||
|
||||
/** [passphrase] encrypts the database file at rest via SQLCipher - see [com.homelab.ncal.data.prefs.SecurePrefs.databaseKey]. */
|
||||
fun get(context: Context, passphrase: ByteArray): AppDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"ncal_encrypted.db"
|
||||
)
|
||||
.openHelperFactory(SupportFactory(passphrase))
|
||||
.fallbackToDestructiveMigration()
|
||||
.build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.homelab.ncal.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity(tableName = "collections")
|
||||
data class CollectionEntity(
|
||||
@PrimaryKey val url: String,
|
||||
val displayName: String,
|
||||
val colorHex: String?,
|
||||
val supportsEvents: Boolean,
|
||||
val supportsTasks: Boolean,
|
||||
val ctag: String?,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface CollectionDao {
|
||||
@Query("SELECT * FROM collections ORDER BY displayName")
|
||||
fun observeAll(): Flow<List<CollectionEntity>>
|
||||
|
||||
@Query("SELECT * FROM collections")
|
||||
suspend fun getAll(): List<CollectionEntity>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(collections: List<CollectionEntity>)
|
||||
|
||||
@Query("UPDATE collections SET enabled = :enabled WHERE url = :url")
|
||||
suspend fun setEnabled(url: String, enabled: Boolean)
|
||||
|
||||
@Query("DELETE FROM collections WHERE url NOT IN (:keepUrls)")
|
||||
suspend fun pruneMissing(keepUrls: List<String>)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package com.homelab.ncal.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity(tableName = "items")
|
||||
data class ItemEntity(
|
||||
@PrimaryKey val href: String,
|
||||
val uid: String,
|
||||
val etag: String?,
|
||||
val calendarUrl: String,
|
||||
val type: String, // "EVENT" | "TASK"
|
||||
val summary: String,
|
||||
val description: String,
|
||||
val location: String,
|
||||
val url: String,
|
||||
val start: Long?,
|
||||
val end: Long?,
|
||||
val allDay: Boolean,
|
||||
val due: Long?,
|
||||
val completed: Boolean,
|
||||
val status: String, // TaskStatus name, tasks only
|
||||
val percentComplete: Int,
|
||||
val priority: Int,
|
||||
val parentUid: String?,
|
||||
val reminderMinutes: String, // comma-separated minutes-before list, "" if none
|
||||
val rawIcs: String?
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ItemDao {
|
||||
@Query("SELECT * FROM items WHERE calendarUrl IN (:calendarUrls) ORDER BY COALESCE(start, due) ASC")
|
||||
fun observeForCalendars(calendarUrls: List<String>): Flow<List<ItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM items WHERE type = 'TASK' AND calendarUrl IN (:calendarUrls) ORDER BY completed ASC, COALESCE(due, 9223372036854775807) ASC")
|
||||
fun observeTasks(calendarUrls: List<String>): Flow<List<ItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM items WHERE type = 'EVENT' AND calendarUrl IN (:calendarUrls) ORDER BY start ASC")
|
||||
fun observeEvents(calendarUrls: List<String>): Flow<List<ItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM items WHERE href = :href LIMIT 1")
|
||||
suspend fun getByHref(href: String): ItemEntity?
|
||||
|
||||
@Query("SELECT * FROM items")
|
||||
suspend fun getAllOnce(): List<ItemEntity>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(items: List<ItemEntity>)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(item: ItemEntity)
|
||||
|
||||
@Query("DELETE FROM items WHERE href = :href")
|
||||
suspend fun deleteByHref(href: String)
|
||||
|
||||
@Query("DELETE FROM items WHERE calendarUrl = :calendarUrl AND href NOT IN (:keepHrefs)")
|
||||
suspend fun pruneMissing(calendarUrl: String, keepHrefs: List<String>)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.homelab.ncal.data.model
|
||||
|
||||
/**
|
||||
* A single CalDAV collection on the server, e.g.
|
||||
* https://nextcloud.example.com/remote.php/dav/calendars/anon/personal/
|
||||
*
|
||||
* A collection can support VEVENT (calendar), VTODO (task list), or both,
|
||||
* depending on how it was created ("New calendar" vs "New calendar with task list").
|
||||
*/
|
||||
data class CalendarCollection(
|
||||
val url: String,
|
||||
val displayName: String,
|
||||
val colorHex: String?,
|
||||
val supportsEvents: Boolean,
|
||||
val supportsTasks: Boolean,
|
||||
val ctag: String?,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.homelab.ncal.data.model
|
||||
|
||||
enum class ItemType { EVENT, TASK }
|
||||
|
||||
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
|
||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||
|
||||
/**
|
||||
* A single VEVENT or VTODO. Times are stored as epoch millis (UTC).
|
||||
* [rawIcs] retains the full round-tripped iCalendar object so fields we don't
|
||||
* explicitly model (RRULE, alarms, attendees, etc.) survive an edit-and-save cycle.
|
||||
*/
|
||||
data class CalendarItem(
|
||||
val uid: String,
|
||||
val href: String, // full resource URL, empty string for a not-yet-created item
|
||||
val etag: String?, // null for a not-yet-created item
|
||||
val calendarUrl: String, // which collection this belongs / will belong to
|
||||
val type: ItemType,
|
||||
|
||||
val summary: String,
|
||||
val description: String = "",
|
||||
val location: String = "",
|
||||
val url: String = "",
|
||||
|
||||
val start: Long? = null,
|
||||
val end: Long? = null,
|
||||
val allDay: Boolean = false,
|
||||
|
||||
val due: Long? = null, // tasks only
|
||||
val completed: Boolean = false, // tasks only - kept in sync with [status] == COMPLETED
|
||||
val status: TaskStatus = TaskStatus.NEEDS_ACTION, // tasks only
|
||||
val percentComplete: Int = 0, // tasks only, 0-100
|
||||
val priority: Int = 0, // 0 = undefined, 1 (high) .. 9 (low), iCal convention
|
||||
val parentUid: String? = null, // tasks only: UID of the parent task (iCal RELATED-TO)
|
||||
val reminderMinutes: List<Int> = emptyList(), // minutes before start (events) / due (tasks) to notify
|
||||
|
||||
val rawIcs: String? = null
|
||||
) {
|
||||
val isNew: Boolean get() = href.isEmpty()
|
||||
|
||||
/** Sets task completion consistently across [status]/[completed]/[percentComplete] so they never drift. */
|
||||
fun withStatus(newStatus: TaskStatus): CalendarItem = copy(
|
||||
status = newStatus,
|
||||
completed = newStatus == TaskStatus.COMPLETED,
|
||||
percentComplete = if (newStatus == TaskStatus.COMPLETED) 100 else percentComplete
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package com.homelab.ncal.data.network
|
||||
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class CalDavException(message: String, val httpCode: Int? = null) : IOException(message)
|
||||
|
||||
private val XML_MEDIA = "application/xml; charset=utf-8".toMediaType()
|
||||
private val ICAL_MEDIA = "text/calendar; charset=utf-8".toMediaType()
|
||||
|
||||
/**
|
||||
* Speaks just enough CalDAV/WebDAV to drive NCal: discover calendars under a user's
|
||||
* calendar-home, list/fetch VEVENT+VTODO objects, and create/update/delete them.
|
||||
*
|
||||
* This intentionally targets Nextcloud's DAV layout directly
|
||||
* (https://<server>/remote.php/dav/calendars/<user>/) rather than doing full
|
||||
* RFC 4791 principal discovery, since that path is stable across Nextcloud versions.
|
||||
*/
|
||||
class CalDavClient(
|
||||
private val serverUrl: String,
|
||||
private val username: String,
|
||||
private val appPassword: String
|
||||
) {
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val authHeader = Credentials.basic(username, appPassword)
|
||||
|
||||
val calendarHomeUrl: String
|
||||
get() = "${serverUrl.trimEnd('/')}/remote.php/dav/calendars/$username/"
|
||||
|
||||
/** Verifies the credentials/URL by attempting to list the calendar home. */
|
||||
fun testConnection() {
|
||||
discoverCollections()
|
||||
}
|
||||
|
||||
/** Lists all calendars/task-lists under the user's calendar home. */
|
||||
fun discoverCollections(): List<DavResponse> {
|
||||
val body = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:apple="http://apple.com/ns/ical/">
|
||||
<d:prop>
|
||||
<d:resourcetype/>
|
||||
<d:displayname/>
|
||||
<cs:getctag/>
|
||||
<c:supported-calendar-component-set/>
|
||||
<apple:calendar-color/>
|
||||
</d:prop>
|
||||
</d:propfind>""".trimIndent()
|
||||
|
||||
val response = execute(calendarHomeUrl, "PROPFIND", body, mapOf("Depth" to "1"))
|
||||
val all = DavXml.parseMultistatus(response)
|
||||
|
||||
android.util.Log.d("NCalDiscovery", "PROPFIND to $calendarHomeUrl returned ${all.size} raw response(s):")
|
||||
all.forEach { dav ->
|
||||
android.util.Log.d(
|
||||
"NCalDiscovery",
|
||||
" href=${dav.href} displayName=${dav.displayName} resourceTypes=${dav.resourceTypes} supportedComponents=${dav.supportedComponents}"
|
||||
)
|
||||
}
|
||||
|
||||
// Drop the home collection itself and Nextcloud's internal inbox/outbox/trashbin.
|
||||
// Prefer the 'calendar' resourcetype marker, but fall back to "has real
|
||||
// supported-components" - some calendars (created outside Nextcloud's own "New
|
||||
// calendar" UI, e.g. imported or created via a raw CalDAV client) come back
|
||||
// without the resourcetype marker even though they're perfectly valid VEVENT/
|
||||
// VTODO collections. The system collections always report empty components, so
|
||||
// this fallback doesn't risk pulling in anything that isn't a real calendar.
|
||||
val filtered = all.filter {
|
||||
(it.resourceTypes.contains("calendar") || it.supportedComponents.isNotEmpty()) &&
|
||||
!it.href.trimEnd('/').endsWith(username)
|
||||
}
|
||||
val dropped = all - filtered.toSet()
|
||||
if (dropped.isNotEmpty()) {
|
||||
android.util.Log.d("NCalDiscovery", "Filtered OUT ${dropped.size} response(s) (no 'calendar' resourcetype, or is the home collection itself):")
|
||||
dropped.forEach { android.util.Log.d("NCalDiscovery", " dropped href=${it.href} resourceTypes=${it.resourceTypes}") }
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
/** Fetches raw iCalendar data for every VEVENT (or VTODO) object in a collection within [start, end]. */
|
||||
fun fetchItems(collectionUrl: String, component: String, start: java.util.Date, end: java.util.Date): List<DavResponse> {
|
||||
val body = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="$component">
|
||||
<c:time-range start="${formatIcsUtc(start)}" end="${formatIcsUtc(end)}"/>
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>""".trimIndent()
|
||||
|
||||
val response = execute(collectionUrl, "REPORT", body, mapOf("Depth" to "1"))
|
||||
return DavXml.parseMultistatus(response).filter { it.calendarData != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every object of [component] with no time bound. Used for VTODO: a time-range
|
||||
* filter can exclude tasks that have neither DTSTART nor DUE set (RFC 4791 \u00a79.9), which
|
||||
* would silently drop plain undated checklist-style tasks - common enough that it's not
|
||||
* worth the tradeoff just to bound the query.
|
||||
*/
|
||||
fun fetchItems(collectionUrl: String, component: String): List<DavResponse> {
|
||||
val body = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="$component"/>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>""".trimIndent()
|
||||
|
||||
val response = execute(collectionUrl, "REPORT", body, mapOf("Depth" to "1"))
|
||||
return DavXml.parseMultistatus(response).filter { it.calendarData != null }
|
||||
}
|
||||
|
||||
private fun formatIcsUtc(date: java.util.Date): String {
|
||||
val fmt = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", java.util.Locale.US)
|
||||
fmt.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
return fmt.format(date)
|
||||
}
|
||||
|
||||
/** Creates a new object. Returns the href it was created at. */
|
||||
/** Returns the href and the new etag the server assigned (if it returned one). */
|
||||
fun createItem(collectionUrl: String, ics: String, uid: String = UUID.randomUUID().toString()): Pair<String, String?> {
|
||||
val href = "${collectionUrl.trimEnd('/')}/$uid.ics"
|
||||
val request = Request.Builder()
|
||||
.url(href)
|
||||
.header("Authorization", authHeader)
|
||||
.header("If-None-Match", "*")
|
||||
.put(ics.toRequestBody(ICAL_MEDIA))
|
||||
.build()
|
||||
val etag = executeRawEtag(request, expectBodyOnError = true)
|
||||
return href to etag
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing object. [etag] should be the last known etag for conflict safety.
|
||||
* Returns the new etag the server assigned (if it returned one) - a PUT always changes the
|
||||
* resource's etag server-side, so callers must persist this or every subsequent edit will
|
||||
* 412 against the now-stale etag they still hold locally.
|
||||
*/
|
||||
fun updateItem(href: String, ics: String, etag: String?): String? {
|
||||
val builder = Request.Builder()
|
||||
.url(href)
|
||||
.header("Authorization", authHeader)
|
||||
.put(ics.toRequestBody(ICAL_MEDIA))
|
||||
if (!etag.isNullOrBlank()) builder.header("If-Match", etag)
|
||||
return executeRawEtag(builder.build(), expectBodyOnError = true)
|
||||
}
|
||||
|
||||
fun deleteItem(href: String, etag: String?) {
|
||||
val builder = Request.Builder()
|
||||
.url(href)
|
||||
.header("Authorization", authHeader)
|
||||
.delete()
|
||||
if (!etag.isNullOrBlank()) builder.header("If-Match", etag)
|
||||
executeRaw(builder.build(), expectBodyOnError = true)
|
||||
}
|
||||
|
||||
private fun execute(url: String, method: String, xmlBody: String, headers: Map<String, String>): String {
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", authHeader)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.method(method, xmlBody.toRequestBody(XML_MEDIA))
|
||||
headers.forEach { (k, v) -> builder.header(k, v) }
|
||||
val request = builder.build()
|
||||
return executeRaw(request, expectBodyOnError = true) ?: ""
|
||||
}
|
||||
|
||||
private fun executeRaw(request: Request, expectBodyOnError: Boolean): String? {
|
||||
http.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
val detail = if (expectBodyOnError) resp.body?.string()?.take(300) else null
|
||||
throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code)
|
||||
}
|
||||
return resp.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
/** Like [executeRaw], but returns the response's ETag header instead of its body. */
|
||||
private fun executeRawEtag(request: Request, expectBodyOnError: Boolean): String? {
|
||||
http.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
val detail = if (expectBodyOnError) resp.body?.string()?.take(300) else null
|
||||
throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code)
|
||||
}
|
||||
return resp.header("ETag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.homelab.ncal.data.network
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.StringReader
|
||||
|
||||
/** One <d:response> entry inside a <d:multistatus> WebDAV/CalDAV reply. */
|
||||
data class DavResponse(
|
||||
val href: String,
|
||||
val displayName: String? = null,
|
||||
val resourceTypes: Set<String> = emptySet(),
|
||||
val supportedComponents: Set<String> = emptySet(),
|
||||
val ctag: String? = null,
|
||||
val etag: String? = null,
|
||||
val colorHex: String? = null,
|
||||
val calendarData: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Minimal streaming parser for WebDAV multistatus XML. We only pull the handful
|
||||
* of properties NCal actually uses, ignoring namespaces beyond local-name matching
|
||||
* (Nextcloud/sabre and other CalDAV servers are consistent enough about local names
|
||||
* that this is reliable in practice, and it avoids pulling in a full XML DOM lib).
|
||||
*
|
||||
* IMPORTANT: a single <response> can contain multiple <propstat> blocks - one per
|
||||
* distinct status code. A calendar that has, say, resourcetype/displayname but no
|
||||
* custom calendar-color set will get its color property back in a *separate*
|
||||
* <propstat> with status 404, alongside a 200 <propstat> for everything else the
|
||||
* server does have. Status must be tracked per-propstat and merged only from the
|
||||
* ones that succeeded - not as one flag for the whole <response> - or perfectly
|
||||
* valid calendars/task-lists silently vanish depending on which propstat happens
|
||||
* to be parsed last.
|
||||
*/
|
||||
object DavXml {
|
||||
|
||||
fun parseMultistatus(body: String): List<DavResponse> {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
val parser = factory.newPullParser()
|
||||
parser.setInput(StringReader(body))
|
||||
|
||||
val responses = mutableListOf<DavResponse>()
|
||||
|
||||
// Response-level accumulators - only ever filled from a *successful* propstat.
|
||||
var href: String? = null
|
||||
var displayName: String? = null
|
||||
val resourceTypes = mutableSetOf<String>()
|
||||
val supportedComponents = mutableSetOf<String>()
|
||||
var ctag: String? = null
|
||||
var etag: String? = null
|
||||
var colorHex: String? = null
|
||||
var calendarData: String? = null
|
||||
|
||||
// Per-propstat scratch space, flushed into the accumulators above on </propstat>
|
||||
// only if that propstat's status was 2xx.
|
||||
var inPropstat = false
|
||||
var propOk = true
|
||||
var propDisplayName: String? = null
|
||||
val propResourceTypes = mutableSetOf<String>()
|
||||
val propSupportedComponents = mutableSetOf<String>()
|
||||
var propCtag: String? = null
|
||||
var propEtag: String? = null
|
||||
var propColorHex: String? = null
|
||||
var propCalendarData: String? = null
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
when (parser.name) {
|
||||
"response" -> {
|
||||
href = null; displayName = null; ctag = null; etag = null
|
||||
colorHex = null; calendarData = null
|
||||
resourceTypes.clear(); supportedComponents.clear()
|
||||
}
|
||||
"propstat" -> {
|
||||
inPropstat = true
|
||||
propOk = true
|
||||
propDisplayName = null; propCtag = null; propEtag = null
|
||||
propColorHex = null; propCalendarData = null
|
||||
propResourceTypes.clear(); propSupportedComponents.clear()
|
||||
}
|
||||
"href" -> href = parser.nextTextSafe()
|
||||
"displayname" -> if (inPropstat) propDisplayName = parser.nextTextSafe()
|
||||
"collection" -> if (inPropstat) propResourceTypes.add("collection")
|
||||
"calendar" -> if (inPropstat) propResourceTypes.add("calendar")
|
||||
"comp" -> if (inPropstat) parser.getAttributeValue(null, "name")?.let { propSupportedComponents.add(it) }
|
||||
"getctag" -> if (inPropstat) propCtag = parser.nextTextSafe()
|
||||
"getetag" -> if (inPropstat) propEtag = parser.nextTextSafe()
|
||||
"calendar-color" -> if (inPropstat) propColorHex = parser.nextTextSafe()?.take(7)
|
||||
"calendar-data" -> if (inPropstat) propCalendarData = parser.nextTextSafe()
|
||||
"status" -> {
|
||||
val status = parser.nextTextSafe() ?: ""
|
||||
if (inPropstat) propOk = status.contains(" 2") // e.g. "HTTP/1.1 200 OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
when (parser.name) {
|
||||
"propstat" -> {
|
||||
if (propOk) {
|
||||
propDisplayName?.let { displayName = it }
|
||||
propCtag?.let { ctag = it }
|
||||
propEtag?.let { etag = it }
|
||||
propColorHex?.let { colorHex = it }
|
||||
propCalendarData?.let { calendarData = it }
|
||||
resourceTypes.addAll(propResourceTypes)
|
||||
supportedComponents.addAll(propSupportedComponents)
|
||||
}
|
||||
inPropstat = false
|
||||
}
|
||||
"response" -> {
|
||||
if (href != null) {
|
||||
responses.add(
|
||||
DavResponse(
|
||||
href = href!!,
|
||||
displayName = displayName,
|
||||
resourceTypes = resourceTypes.toSet(),
|
||||
supportedComponents = supportedComponents.toSet(),
|
||||
ctag = ctag,
|
||||
etag = etag,
|
||||
colorHex = colorHex,
|
||||
calendarData = calendarData
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
/** Reads the text content of the current element without throwing on empty elements. */
|
||||
private fun XmlPullParser.nextTextSafe(): String? {
|
||||
return try {
|
||||
this.nextText()?.takeIf { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.homelab.ncal.data.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Holds the Nextcloud server base URL + login used to build CalDAV requests.
|
||||
* Use a per-user "app password" (Nextcloud Settings -> Security -> Devices & sessions
|
||||
* -> Create new app password), never the account's real password.
|
||||
*/
|
||||
class SecurePrefs(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"ncal_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
var serverUrl: String?
|
||||
get() = prefs.getString(KEY_SERVER_URL, null)
|
||||
set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply()
|
||||
|
||||
var username: String?
|
||||
get() = prefs.getString(KEY_USERNAME, null)
|
||||
set(value) = prefs.edit().putString(KEY_USERNAME, value).apply()
|
||||
|
||||
var appPassword: String?
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit().putString(KEY_APP_PASSWORD, value).apply()
|
||||
|
||||
/** Base URL for this user's CalDAV calendar home, e.g. https://host/remote.php/dav/calendars/anon/ */
|
||||
val calendarHomeUrl: String?
|
||||
get() {
|
||||
val server = serverUrl?.trimEnd('/') ?: return null
|
||||
val user = username ?: return null
|
||||
return "$server/remote.php/dav/calendars/$user/"
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean
|
||||
get() = serverUrl != null && username != null && appPassword != null
|
||||
|
||||
/**
|
||||
* Random passphrase for the local SQLCipher-encrypted Room database, generated once and
|
||||
* kept in the same Keystore-backed encrypted prefs as the server credentials. Deliberately
|
||||
* NOT cleared by [clear] - logging out shouldn't strand the local DB with an unrecoverable
|
||||
* key, since a re-login should be able to read the same cache back.
|
||||
*/
|
||||
val databaseKey: ByteArray
|
||||
get() {
|
||||
val existing = prefs.getString(KEY_DB_KEY, null)
|
||||
if (existing != null) return Base64.decode(existing, Base64.NO_WRAP)
|
||||
val generated = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||
prefs.edit().putString(KEY_DB_KEY, Base64.encodeToString(generated, Base64.NO_WRAP)).apply()
|
||||
return generated
|
||||
}
|
||||
|
||||
/** Clears login state only - see [databaseKey] for why that survives a logout. */
|
||||
fun clear() {
|
||||
prefs.edit()
|
||||
.remove(KEY_SERVER_URL)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_APP_PASSWORD)
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_APP_PASSWORD = "app_password"
|
||||
private const val KEY_DB_KEY = "database_key"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package com.homelab.ncal.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.homelab.ncal.data.db.AppDatabase
|
||||
import com.homelab.ncal.data.db.CollectionEntity
|
||||
import com.homelab.ncal.data.db.ItemEntity
|
||||
import com.homelab.ncal.data.model.CalendarCollection
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.data.model.TaskStatus
|
||||
import com.homelab.ncal.data.network.CalDavClient
|
||||
import com.homelab.ncal.data.prefs.SecurePrefs
|
||||
import com.homelab.ncal.notifications.ReminderScheduler
|
||||
import com.homelab.ncal.util.IcsMapper
|
||||
import com.homelab.ncal.widget.MonthGridWidget
|
||||
import com.homelab.ncal.widget.NextTasksWidget
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NextcloudRepository(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val prefs = SecurePrefs(context)
|
||||
private val db = AppDatabase.get(context, prefs.databaseKey)
|
||||
|
||||
private fun client(): CalDavClient {
|
||||
val server = prefs.serverUrl ?: error("Not logged in")
|
||||
val user = prefs.username ?: error("Not logged in")
|
||||
val pass = prefs.appPassword ?: error("Not logged in")
|
||||
return CalDavClient(server, user, pass)
|
||||
}
|
||||
|
||||
val isLoggedIn: Boolean get() = prefs.isLoggedIn
|
||||
|
||||
suspend fun login(serverUrl: String, username: String, appPassword: String) = withContext(Dispatchers.IO) {
|
||||
// Throws CalDavException if the credentials/URL are bad.
|
||||
CalDavClient(serverUrl, username, appPassword).testConnection()
|
||||
prefs.serverUrl = serverUrl
|
||||
prefs.username = username
|
||||
prefs.appPassword = appPassword
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
prefs.clear()
|
||||
}
|
||||
|
||||
// ---------- Collections ----------
|
||||
|
||||
fun observeCollections(): Flow<List<CalendarCollection>> =
|
||||
db.collectionDao().observeAll().map { list -> list.map { it.toModel() } }
|
||||
|
||||
suspend fun setCollectionEnabled(url: String, enabled: Boolean) =
|
||||
db.collectionDao().setEnabled(url, enabled)
|
||||
|
||||
/** Refreshes the list of calendars/task-lists from the server. */
|
||||
suspend fun syncCollections() = withContext(Dispatchers.IO) {
|
||||
val remote = client().discoverCollections()
|
||||
val existing = db.collectionDao().getAll().associateBy { it.url }
|
||||
|
||||
val entities = remote.map { dav ->
|
||||
val comps = dav.supportedComponents
|
||||
CollectionEntity(
|
||||
url = dav.href.toAbsolute(),
|
||||
displayName = dav.displayName ?: dav.href.trimEnd('/').substringAfterLast('/'),
|
||||
colorHex = dav.colorHex,
|
||||
supportsEvents = comps.isEmpty() || comps.contains("VEVENT"),
|
||||
supportsTasks = comps.contains("VTODO"),
|
||||
ctag = dav.ctag,
|
||||
enabled = existing[dav.href.toAbsolute()]?.enabled ?: true
|
||||
)
|
||||
}
|
||||
db.collectionDao().upsertAll(entities)
|
||||
db.collectionDao().pruneMissing(entities.map { it.url })
|
||||
}
|
||||
|
||||
// ---------- Items (events + tasks) ----------
|
||||
|
||||
fun observeItems(calendarUrls: List<String>): Flow<List<CalendarItem>> =
|
||||
db.itemDao().observeForCalendars(calendarUrls).map { list -> list.map { it.toModel() } }
|
||||
|
||||
fun observeTasks(calendarUrls: List<String>): Flow<List<CalendarItem>> =
|
||||
db.itemDao().observeTasks(calendarUrls).map { list -> list.map { it.toModel() } }
|
||||
|
||||
fun observeEvents(calendarUrls: List<String>): Flow<List<CalendarItem>> =
|
||||
db.itemDao().observeEvents(calendarUrls).map { list -> list.map { it.toModel() } }
|
||||
|
||||
suspend fun itemByHref(href: String): CalendarItem? = withContext(Dispatchers.IO) {
|
||||
db.itemDao().getByHref(href)?.toModel()
|
||||
}
|
||||
|
||||
suspend fun allCachedItems(): List<CalendarItem> = withContext(Dispatchers.IO) {
|
||||
db.itemDao().getAllOnce().map { it.toModel() }
|
||||
}
|
||||
|
||||
/** Pulls fresh events + tasks for every enabled collection. */
|
||||
suspend fun syncItems() = withContext(Dispatchers.IO) {
|
||||
val c = client()
|
||||
val collections = db.collectionDao().getAll().filter { it.enabled }
|
||||
android.util.Log.d("NCalSync", "Syncing ${collections.size} enabled collection(s): ${collections.map { "${it.displayName}(events=${it.supportsEvents},tasks=${it.supportsTasks})" }}")
|
||||
|
||||
val startCal = java.util.Calendar.getInstance()
|
||||
startCal.add(java.util.Calendar.DAY_OF_YEAR, -1)
|
||||
val windowStart = startCal.time
|
||||
|
||||
val endCal = java.util.Calendar.getInstance()
|
||||
endCal.add(java.util.Calendar.YEAR, 1)
|
||||
val windowEnd = endCal.time
|
||||
|
||||
for (col in collections) {
|
||||
val keepHrefs = mutableListOf<String>()
|
||||
|
||||
if (col.supportsEvents) {
|
||||
val remote = c.fetchItems(col.url, "VEVENT", windowStart, windowEnd)
|
||||
keepHrefs += remote.map { it.href.toAbsolute() }
|
||||
val entities = remote.mapNotNull { dav ->
|
||||
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
|
||||
}
|
||||
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VEVENT(s), parsed ${entities.size}")
|
||||
db.itemDao().upsertAll(entities)
|
||||
}
|
||||
if (col.supportsTasks) {
|
||||
val remote = c.fetchItems(col.url, "VTODO")
|
||||
keepHrefs += remote.map { it.href.toAbsolute() }
|
||||
val entities = remote.mapNotNull { dav ->
|
||||
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
|
||||
}
|
||||
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VTODO(s), parsed ${entities.size}")
|
||||
db.itemDao().upsertAll(entities)
|
||||
}
|
||||
if (col.supportsEvents || col.supportsTasks) {
|
||||
db.itemDao().pruneMissing(col.url, keepHrefs)
|
||||
}
|
||||
}
|
||||
|
||||
ReminderScheduler.rescheduleAll(appContext, db.itemDao().getAllOnce().map { it.toModel() })
|
||||
updateWidgets()
|
||||
}
|
||||
|
||||
suspend fun fullSync() {
|
||||
syncCollections()
|
||||
syncItems()
|
||||
}
|
||||
|
||||
/** Creates or updates an item both on the server and in the local cache. */
|
||||
suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
|
||||
val c = client()
|
||||
val ics = IcsMapper.toIcs(item)
|
||||
|
||||
val saved = if (item.isNew) {
|
||||
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
|
||||
item.copy(href = href, etag = etag, rawIcs = ics)
|
||||
} else {
|
||||
val etag = c.updateItem(item.href, ics, item.etag)
|
||||
item.copy(etag = etag, rawIcs = ics)
|
||||
}
|
||||
db.itemDao().upsert(saved.toEntity())
|
||||
ReminderScheduler.reschedule(appContext, saved)
|
||||
updateWidgets()
|
||||
saved
|
||||
}
|
||||
|
||||
suspend fun delete(item: CalendarItem) = withContext(Dispatchers.IO) {
|
||||
if (!item.isNew) {
|
||||
client().deleteItem(item.href, item.etag)
|
||||
}
|
||||
db.itemDao().deleteByHref(item.href)
|
||||
ReminderScheduler.cancel(appContext, item)
|
||||
updateWidgets()
|
||||
}
|
||||
|
||||
suspend fun toggleTaskComplete(item: CalendarItem): CalendarItem =
|
||||
save(item.withStatus(if (item.completed) TaskStatus.NEEDS_ACTION else TaskStatus.COMPLETED))
|
||||
|
||||
private suspend fun updateWidgets() {
|
||||
MonthGridWidget().updateAll(appContext)
|
||||
NextTasksWidget().updateAll(appContext)
|
||||
}
|
||||
|
||||
// ---------- mapping helpers ----------
|
||||
|
||||
private fun CollectionEntity.toModel() = CalendarCollection(
|
||||
url = url, displayName = displayName, colorHex = colorHex,
|
||||
supportsEvents = supportsEvents, supportsTasks = supportsTasks, ctag = ctag, enabled = enabled
|
||||
)
|
||||
|
||||
private fun ItemEntity.toModel() = CalendarItem(
|
||||
uid = uid, href = href, etag = etag, calendarUrl = calendarUrl,
|
||||
type = ItemType.valueOf(type), summary = summary, description = description, location = location, url = url,
|
||||
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
||||
status = runCatching { TaskStatus.valueOf(status) }.getOrDefault(TaskStatus.NEEDS_ACTION),
|
||||
percentComplete = percentComplete, priority = priority,
|
||||
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(), rawIcs = rawIcs
|
||||
)
|
||||
|
||||
private fun CalendarItem.toEntity() = ItemEntity(
|
||||
href = href, uid = uid, etag = etag, calendarUrl = calendarUrl, type = type.name,
|
||||
summary = summary, description = description, location = location, url = url,
|
||||
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
||||
status = status.name, percentComplete = percentComplete,
|
||||
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(), rawIcs = rawIcs
|
||||
)
|
||||
|
||||
private fun String.toMinutesList(): List<Int> =
|
||||
if (isBlank()) emptyList() else split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
|
||||
private fun List<Int>.toCsv(): String = joinToString(",")
|
||||
|
||||
/** Server sometimes returns relative hrefs; normalize against the calendar home. */
|
||||
private fun String.toAbsolute(): String =
|
||||
if (this.startsWith("http")) this else "${prefs.serverUrl?.trimEnd('/')}$this"
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.homelab.ncal.notifications
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Alarms don't survive a reboot or app update, so re-arm every cached item's reminder. */
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val app = context.applicationContext as NcalApplication
|
||||
if (!app.repository.isLoggedIn) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ReminderScheduler.rescheduleAll(context, app.repository.allCachedItems())
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.homelab.ncal.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.homelab.ncal.MainActivity
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import com.homelab.ncal.R
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
const val CHANNEL_ID = "reminders"
|
||||
const val EXTRA_HREF = "href"
|
||||
|
||||
/** Fires at an item's next reminder time: shows a notification, then re-arms the following offset. */
|
||||
class ReminderReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val href = intent.getStringExtra(EXTRA_HREF) ?: return
|
||||
val app = context.applicationContext as NcalApplication
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val item = app.repository.itemByHref(href)
|
||||
if (item != null) {
|
||||
showNotification(context, item)
|
||||
ReminderScheduler.reschedule(context, item)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotification(context: Context, item: CalendarItem) {
|
||||
if (item.type == ItemType.TASK && item.completed) return
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val anchor = if (item.type == ItemType.EVENT) item.start else item.due
|
||||
val contentText = anchor?.let {
|
||||
val label = if (item.type == ItemType.EVENT) "Starts" else "Due"
|
||||
"$label ${SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()).format(Date(it))}"
|
||||
} ?: ""
|
||||
|
||||
val contentIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name)
|
||||
putExtra(MainActivity.EXTRA_ITEM_HREF, item.href)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
item.href.hashCode(),
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(item.summary)
|
||||
.setContentText(contentText)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(item.href.hashCode(), notification)
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.homelab.ncal.notifications
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
|
||||
/**
|
||||
* Schedules a single exact alarm per item, always set for the item's *next* upcoming reminder
|
||||
* offset. [ReminderReceiver] re-arms the following offset (if any) each time it fires, so there's
|
||||
* never more than one pending alarm per item to track or cancel.
|
||||
*/
|
||||
object ReminderScheduler {
|
||||
|
||||
fun cancel(context: Context, item: CalendarItem) {
|
||||
if (item.href.isEmpty()) return
|
||||
val pendingIntent = pendingIntentFor(context, item.href, create = false) ?: return
|
||||
alarmManager(context).cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
}
|
||||
|
||||
fun reschedule(context: Context, item: CalendarItem) {
|
||||
cancel(context, item)
|
||||
if (item.href.isEmpty()) return
|
||||
if (item.type == ItemType.TASK && item.completed) return
|
||||
|
||||
val anchor = if (item.type == ItemType.EVENT) item.start else item.due
|
||||
if (anchor == null || item.reminderMinutes.isEmpty()) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val nextTrigger = item.reminderMinutes
|
||||
.map { anchor - it * 60_000L }
|
||||
.filter { it > now }
|
||||
.minOrNull() ?: return
|
||||
|
||||
val pendingIntent = pendingIntentFor(context, item.href, create = true) ?: return
|
||||
val manager = alarmManager(context)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !manager.canScheduleExactAlarms()) {
|
||||
manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTrigger, pendingIntent)
|
||||
} else {
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTrigger, pendingIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun rescheduleAll(context: Context, items: List<CalendarItem>) {
|
||||
items.forEach { reschedule(context, it) }
|
||||
}
|
||||
|
||||
private fun alarmManager(context: Context) =
|
||||
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
private fun pendingIntentFor(context: Context, href: String, create: Boolean): PendingIntent? {
|
||||
val intent = Intent(context, ReminderReceiver::class.java).apply {
|
||||
putExtra(EXTRA_HREF, href)
|
||||
}
|
||||
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
return PendingIntent.getBroadcast(context, href.hashCode(), intent, flags)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.homelab.ncal.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
|
||||
/**
|
||||
* Small hand-rolled factory so ViewModels can take a [NextcloudRepository] (and, where
|
||||
* needed, the navigation [SavedStateHandle]) without pulling in a DI framework.
|
||||
*/
|
||||
class NcalViewModelFactory(
|
||||
private val app: NcalApplication,
|
||||
private val create: (NextcloudRepository, SavedStateHandle) -> ViewModel
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||
val handle = extras.createSavedStateHandle()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return create(app.repository, handle) as T
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package com.homelab.ncal.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
viewModel: AgendaViewModel,
|
||||
onOpenItem: (CalendarItem) -> Unit,
|
||||
onNewEvent: () -> Unit,
|
||||
onOpenTasks: () -> Unit,
|
||||
onOpenCollections: () -> Unit,
|
||||
onOpenMonth: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Agenda") },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }
|
||||
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
|
||||
IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") }
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") }
|
||||
}
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding).fillMaxSize()) {
|
||||
state.error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.refreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (state.itemsByDay.isEmpty() && !state.refreshing) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Nothing here yet. Pull to refresh, or check enabled calendars.")
|
||||
}
|
||||
}
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 88.dp)) {
|
||||
state.itemsByDay.forEach { (day, dayItems) ->
|
||||
item(key = "header-$day") {
|
||||
Text(
|
||||
text = day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
items(dayItems, key = { "$day-${it.href.ifEmpty { it.uid }}" }) { agendaItem ->
|
||||
AgendaRow(
|
||||
item = agendaItem,
|
||||
onClick = { onOpenItem(agendaItem) },
|
||||
onToggleComplete = { viewModel.toggleTask(agendaItem) }
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaRow(
|
||||
item: CalendarItem,
|
||||
onClick: () -> Unit,
|
||||
onToggleComplete: () -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickableCompat(onClick),
|
||||
headlineContent = {
|
||||
Text(
|
||||
item.summary,
|
||||
textDecoration = if (item.completed) TextDecoration.LineThrough else null
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
val time = item.start?.let { formatTime(it, item.allDay) }
|
||||
?: item.due?.let { "Due " + formatTime(it, false) }
|
||||
time?.let { Text(it) }
|
||||
},
|
||||
leadingContent = {
|
||||
if (item.type == ItemType.TASK) {
|
||||
Icon(
|
||||
imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = "Toggle complete",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickableCompat(onToggleComplete)
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Filled.Event, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long, allDay: Boolean): String {
|
||||
val zone = java.time.ZoneId.systemDefault()
|
||||
val instant = java.time.Instant.ofEpochMilli(millis)
|
||||
return if (allDay) {
|
||||
"All day"
|
||||
} else {
|
||||
instant.atZone(zone).format(DateTimeFormatter.ofPattern("h:mm a"))
|
||||
}
|
||||
}
|
||||
|
||||
// Small helper so leading icons and rows are tappable without pulling in indication ripple boilerplate everywhere.
|
||||
private fun Modifier.clickableCompat(onClick: () -> Unit): Modifier =
|
||||
this.then(Modifier.clickable(onClick = onClick))
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.homelab.ncal.ui.agenda
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import com.homelab.ncal.util.groupByAllOccupiedDays
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
data class AgendaUiState(
|
||||
val itemsByDay: List<Pair<LocalDate, List<CalendarItem>>> = emptyList(),
|
||||
val refreshing: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class AgendaViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||
|
||||
private val refreshing = MutableStateFlow(false)
|
||||
private val error = MutableStateFlow<String?>(null)
|
||||
|
||||
private val items = repo.observeCollections()
|
||||
.flatMapLatest { collections ->
|
||||
val urls = collections.filter { it.enabled }.map { it.url }
|
||||
repo.observeItems(urls)
|
||||
}
|
||||
|
||||
val state: StateFlow<AgendaUiState> = combine(items, refreshing, error) { itemList, r, e ->
|
||||
AgendaUiState(groupByDay(itemList), r, e)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AgendaUiState())
|
||||
|
||||
private fun groupByDay(items: List<CalendarItem>): List<Pair<LocalDate, List<CalendarItem>>> {
|
||||
val zone = ZoneId.systemDefault()
|
||||
return items.groupByAllOccupiedDays(zone)
|
||||
.toSortedMap()
|
||||
.map { (day, list) -> day to list.sortedBy { it.start ?: it.due } }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshing.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.fullSync()
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "Agenda fullSync failed", e)
|
||||
error.value = e.toUserMessage()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTask(item: CalendarItem) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.toggleTaskComplete(item)
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "toggleTask failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||
error.value = e.toUserMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.homelab.ncal.ui.collections
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CollectionsScreen(
|
||||
viewModel: CollectionsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Calendars & task lists") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "Refresh") }
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
if (state.refreshing) {
|
||||
CircularProgressIndicator(Modifier.padding(16.dp))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
LazyColumn {
|
||||
items(state.collections, key = { it.url }) { col ->
|
||||
ListItem(
|
||||
headlineContent = { Text(col.displayName) },
|
||||
supportingContent = { Text(componentLabel(col.supportsEvents, col.supportsTasks)) },
|
||||
leadingContent = {
|
||||
val color = runCatching { Color(android.graphics.Color.parseColor(col.colorHex ?: "#1F6FEB")) }
|
||||
.getOrDefault(MaterialTheme.colorScheme.primary)
|
||||
Canvas(Modifier.size(16.dp)) {
|
||||
drawCircle(color = color)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(
|
||||
checked = col.enabled,
|
||||
onCheckedChange = { viewModel.setEnabled(col.url, it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun componentLabel(events: Boolean, tasks: Boolean): String = when {
|
||||
events && tasks -> "Events + Tasks"
|
||||
events -> "Events"
|
||||
tasks -> "Tasks"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package com.homelab.ncal.ui.collections
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.model.CalendarCollection
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CollectionsUiState(
|
||||
val collections: List<CalendarCollection> = emptyList(),
|
||||
val refreshing: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||
|
||||
private val refreshing = MutableStateFlow(false)
|
||||
private val error = MutableStateFlow<String?>(null)
|
||||
|
||||
val state: StateFlow<CollectionsUiState> = combine(
|
||||
repo.observeCollections(), refreshing, error
|
||||
) { collections, isRefreshing, err ->
|
||||
CollectionsUiState(collections, isRefreshing, err)
|
||||
}.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState())
|
||||
|
||||
fun refresh() {
|
||||
refreshing.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.fullSync()
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "Collections fullSync failed", e)
|
||||
error.value = e.toUserMessage()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(url: String, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
repo.setCollectionEnabled(url, enabled)
|
||||
if (enabled) {
|
||||
// Newly-enabled collections (or ones re-enabled after being off) have never
|
||||
// had their items fetched - without this they'd sit empty until the user
|
||||
// happens to pull-to-refresh on Agenda/Tasks.
|
||||
try {
|
||||
repo.syncItems()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "syncItems after enabling $url failed", e)
|
||||
error.value = e.toUserMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,538 @@
|
|||
package com.homelab.ncal.ui.detail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.data.model.TaskStatus
|
||||
import com.homelab.ncal.util.priorityColor
|
||||
import com.homelab.ncal.util.priorityLabel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ItemEditScreen(
|
||||
viewModel: ItemEditViewModel,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state.done, state.deleted) {
|
||||
if (state.done || state.deleted) onDone()
|
||||
}
|
||||
|
||||
val item = state.item
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(if (item?.isNew == false) "Edit" else if (item?.type == ItemType.TASK) "New task" else "New event") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDone) { Icon(Icons.Filled.ArrowBack, null) }
|
||||
},
|
||||
actions = {
|
||||
if (item != null && !item.isNew) {
|
||||
IconButton(onClick = { showDeleteConfirm = true }) {
|
||||
Icon(Icons.Filled.Delete, "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (state.loading || item == null) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = item.summary,
|
||||
onValueChange = { v -> viewModel.update { it.copy(summary = v) } },
|
||||
label = { Text("Title") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = item.description,
|
||||
onValueChange = { v -> viewModel.update { it.copy(description = v) } },
|
||||
label = { Text("Description") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = item.location,
|
||||
onValueChange = { v -> viewModel.update { it.copy(location = v) } },
|
||||
label = { Text("Location") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = item.url,
|
||||
onValueChange = { v -> viewModel.update { it.copy(url = v) } },
|
||||
label = { Text("URL") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text("Priority", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(bottom = 4.dp))
|
||||
PriorityPicker(
|
||||
selected = item.priority,
|
||||
onSelect = { p -> viewModel.update { it.copy(priority = p) } }
|
||||
)
|
||||
|
||||
if (item.type == ItemType.EVENT) {
|
||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
DateTimePicker(
|
||||
millis = item.start,
|
||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||
)
|
||||
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||
DateTimePicker(
|
||||
millis = item.end,
|
||||
onChange = { v -> viewModel.update { it.copy(end = v) } }
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = item.allDay,
|
||||
onCheckedChange = { v -> viewModel.update { it.copy(allDay = v) } }
|
||||
)
|
||||
Text("All day")
|
||||
}
|
||||
} else {
|
||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
DateTimePicker(
|
||||
millis = item.start,
|
||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||
)
|
||||
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||
DateTimePicker(
|
||||
millis = item.due,
|
||||
onChange = { v -> viewModel.update { it.copy(due = v) } }
|
||||
)
|
||||
|
||||
Text(
|
||||
"Status",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
StatusPicker(
|
||||
selected = item.status,
|
||||
onSelect = { s -> viewModel.update { it.withStatus(s) } }
|
||||
)
|
||||
|
||||
Text(
|
||||
"% complete: ${item.percentComplete}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Slider(
|
||||
value = item.percentComplete.toFloat(),
|
||||
onValueChange = { v ->
|
||||
val pct = v.toInt()
|
||||
viewModel.update {
|
||||
if (pct >= 100) {
|
||||
it.withStatus(TaskStatus.COMPLETED)
|
||||
} else {
|
||||
it.copy(
|
||||
percentComplete = pct,
|
||||
status = if (it.status == TaskStatus.COMPLETED) TaskStatus.IN_PROCESS else it.status,
|
||||
completed = false
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
steps = 19
|
||||
)
|
||||
|
||||
Text(
|
||||
"Parent task",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
ParentTaskPicker(
|
||||
selectedUid = item.parentUid,
|
||||
options = state.availableParentTasks,
|
||||
onSelect = { uid -> viewModel.update { it.copy(parentUid = uid) } }
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Reminders",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
item.reminderMinutes.sorted().forEach { minutes ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(reminderLabel(minutes))
|
||||
IconButton(onClick = {
|
||||
viewModel.update { it.copy(reminderMinutes = it.reminderMinutes - minutes) }
|
||||
}) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = "Remove reminder")
|
||||
}
|
||||
}
|
||||
}
|
||||
AddReminderButton(
|
||||
existing = item.reminderMinutes,
|
||||
onAdd = { minutes -> viewModel.update { it.copy(reminderMinutes = (it.reminderMinutes + minutes).distinct()) } }
|
||||
)
|
||||
|
||||
Text(
|
||||
"Calendar",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
)
|
||||
CalendarPicker(
|
||||
selectedUrl = item.calendarUrl,
|
||||
options = state.availableCalendars,
|
||||
onSelect = { url -> viewModel.update { it.copy(calendarUrl = url) } }
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 16.dp))
|
||||
}
|
||||
|
||||
androidx.compose.material3.Button(
|
||||
onClick = viewModel::save,
|
||||
enabled = !state.saving,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp)
|
||||
) {
|
||||
if (state.saving) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
|
||||
text = { Text("This can't be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showDeleteConfirm = false
|
||||
viewModel.delete()
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CalendarPicker(
|
||||
selectedUrl: String,
|
||||
options: List<com.homelab.ncal.data.model.CalendarCollection>,
|
||||
onSelect: (String) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedName = options.firstOrNull { it.url == selectedUrl }?.displayName ?: "Select calendar"
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { col ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(col.displayName) },
|
||||
onClick = {
|
||||
onSelect(col.url)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PriorityDot(color: Color?, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color ?: MaterialTheme.colorScheme.outlineVariant)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PriorityPicker(selected: Int, onSelect: (Int) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = priorityLabel(selected),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
leadingIcon = { PriorityDot(priorityColor(selected)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
(0..9).forEach { p ->
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { PriorityDot(priorityColor(p)) },
|
||||
text = { Text(priorityLabel(p)) },
|
||||
onClick = {
|
||||
onSelect(p)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLabel(status: TaskStatus): String = when (status) {
|
||||
TaskStatus.NEEDS_ACTION -> "Not started"
|
||||
TaskStatus.IN_PROCESS -> "In progress"
|
||||
TaskStatus.COMPLETED -> "Completed"
|
||||
TaskStatus.CANCELLED -> "Cancelled"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StatusPicker(selected: TaskStatus, onSelect: (TaskStatus) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = statusLabel(selected),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
TaskStatus.entries.forEach { s ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(statusLabel(s)) },
|
||||
onClick = {
|
||||
onSelect(s)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ParentTaskPicker(
|
||||
selectedUid: String?,
|
||||
options: List<com.homelab.ncal.data.model.CalendarItem>,
|
||||
onSelect: (String?) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedName = options.firstOrNull { it.uid == selectedUid }?.summary ?: "None"
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("None") },
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
options.forEach { task ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(task.summary) },
|
||||
onClick = {
|
||||
onSelect(task.uid)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val reminderPresets = listOf(0, 5, 15, 30, 60, 120, 1440, 2880, 10080)
|
||||
|
||||
private fun reminderLabel(minutes: Int): String = when {
|
||||
minutes <= 0 -> "At time of event"
|
||||
minutes % 10080 == 0 -> "${minutes / 10080} week${if (minutes / 10080 > 1) "s" else ""} before"
|
||||
minutes % 1440 == 0 -> "${minutes / 1440} day${if (minutes / 1440 > 1) "s" else ""} before"
|
||||
minutes % 60 == 0 -> "${minutes / 60} hour${if (minutes / 60 > 1) "s" else ""} before"
|
||||
else -> "$minutes minutes before"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddReminderButton(existing: List<Int>, onAdd: (Int) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val available = reminderPresets.filter { it !in existing }
|
||||
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }, enabled = available.isNotEmpty()) {
|
||||
Text("+ Add reminder")
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
available.forEach { minutes ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(reminderLabel(minutes)) },
|
||||
onClick = {
|
||||
onAdd(minutes)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple date + time picker built on the platform DatePickerDialog/TimePickerDialog. */
|
||||
@Composable
|
||||
private fun DateTimePicker(millis: Long?, onChange: (Long?) -> Unit) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val display = millis?.let { SimpleDateFormat("EEE, MMM d yyyy h:mm a", Locale.getDefault()).format(Date(it)) } ?: "Not set"
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(display, modifier = Modifier.padding(vertical = 8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (millis != null) {
|
||||
TextButton(onClick = { onChange(null) }) {
|
||||
Text("Clear")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val cal = Calendar.getInstance()
|
||||
millis?.let { cal.timeInMillis = it }
|
||||
android.app.DatePickerDialog(context, { _, y, m, d ->
|
||||
cal.set(y, m, d)
|
||||
android.app.TimePickerDialog(context, { _, h, min ->
|
||||
cal.set(Calendar.HOUR_OF_DAY, h)
|
||||
cal.set(Calendar.MINUTE, min)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
onChange(cal.timeInMillis)
|
||||
}, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), false).show()
|
||||
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}) {
|
||||
Text("Set")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.homelab.ncal.ui.detail
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.model.CalendarCollection
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
import java.util.UUID
|
||||
|
||||
data class ItemEditUiState(
|
||||
val item: CalendarItem? = null,
|
||||
val availableCalendars: List<CalendarCollection> = emptyList(),
|
||||
val allTasks: List<CalendarItem> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val done: Boolean = false,
|
||||
val deleted: Boolean = false
|
||||
) {
|
||||
/** Tasks eligible to be set as this task's parent: same calendar, not itself, not one of its own descendants. */
|
||||
val availableParentTasks: List<CalendarItem> get() {
|
||||
val current = item ?: return emptyList()
|
||||
val descendants = mutableSetOf<String>()
|
||||
var frontier = setOf(current.uid)
|
||||
while (frontier.isNotEmpty()) {
|
||||
val children = allTasks.filter { it.parentUid != null && it.parentUid in frontier }
|
||||
.map { it.uid }.toSet() - descendants
|
||||
descendants += children
|
||||
frontier = children
|
||||
}
|
||||
return allTasks.filter {
|
||||
it.calendarUrl == current.calendarUrl && it.uid != current.uid && it.uid !in descendants
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ItemEditViewModel(
|
||||
private val repo: NextcloudRepository,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private val hrefArg: String? = savedStateHandle.get<String>("href")
|
||||
?.let { if (it == "new") null else URLDecoder.decode(it, "UTF-8") }
|
||||
private val typeArg = savedStateHandle.get<String>("type") ?: "EVENT"
|
||||
private val calendarUrlArg: String? = savedStateHandle.get<String>("calendarUrl")
|
||||
?.let { URLDecoder.decode(it, "UTF-8") }.takeUnless { it.isNullOrBlank() || it == "none" }
|
||||
|
||||
private val _state = MutableStateFlow(ItemEditUiState())
|
||||
val state: StateFlow<ItemEditUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val collections = repo.observeCollections().first()
|
||||
val relevantType = ItemType.valueOf(typeArg)
|
||||
val eligibleCalendars = collections.filter {
|
||||
if (relevantType == ItemType.EVENT) it.supportsEvents else it.supportsTasks
|
||||
}
|
||||
|
||||
val loaded = if (hrefArg != null) {
|
||||
findItem(hrefArg, collections)
|
||||
} else {
|
||||
val targetCalendar = calendarUrlArg ?: eligibleCalendars.firstOrNull()?.url
|
||||
CalendarItem(
|
||||
uid = UUID.randomUUID().toString(),
|
||||
href = "",
|
||||
etag = null,
|
||||
calendarUrl = targetCalendar ?: "",
|
||||
type = relevantType,
|
||||
summary = "",
|
||||
start = if (relevantType == ItemType.EVENT) System.currentTimeMillis() else null
|
||||
)
|
||||
}
|
||||
|
||||
val allTasks = if (relevantType == ItemType.TASK) {
|
||||
repo.observeTasks(collections.map { it.url }).first()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
item = loaded,
|
||||
availableCalendars = eligibleCalendars,
|
||||
allTasks = allTasks,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findItem(href: String, collections: List<CalendarCollection>): CalendarItem? {
|
||||
// Items are cached locally; pull from whichever calendar's flow contains it.
|
||||
val urls = collections.map { it.url }
|
||||
return repo.observeItems(urls).first().firstOrNull { it.href == href }
|
||||
}
|
||||
|
||||
fun update(transform: (CalendarItem) -> CalendarItem) {
|
||||
_state.value = _state.value.copy(item = _state.value.item?.let(transform))
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val item = _state.value.item ?: return
|
||||
if (item.summary.isBlank()) {
|
||||
_state.value = _state.value.copy(error = "Title is required")
|
||||
return
|
||||
}
|
||||
if (item.calendarUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(error = "Pick a calendar first")
|
||||
return
|
||||
}
|
||||
_state.value = _state.value.copy(saving = true, error = null)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.save(item)
|
||||
_state.value = _state.value.copy(saving = false, done = true)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "save() failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val item = _state.value.item ?: return
|
||||
if (item.isNew) return
|
||||
_state.value = _state.value.copy(saving = true, error = null)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.delete(item)
|
||||
_state.value = _state.value.copy(saving = false, deleted = true)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "delete() failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.homelab.ncal.ui.login
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel,
|
||||
onLoggedIn: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onLoggedIn()
|
||||
}
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text("Connect to Nextcloud", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"Uses CalDAV directly \u2014 no third-party sync app needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp, top = 4.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = viewModel::onServerUrlChange,
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("nextcloud.yourdomain.com") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.appPassword,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text("App password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)
|
||||
)
|
||||
Text(
|
||||
"Settings \u2192 Security \u2192 Devices & sessions \u2192 Create new app password. Don't use your real login password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp))
|
||||
}
|
||||
|
||||
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(4.dp))
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.homelab.ncal.ui.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoginUiState(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val appPassword: String = "",
|
||||
val loading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val success: Boolean = false
|
||||
)
|
||||
|
||||
class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
fun onServerUrlChange(v: String) { _state.value = _state.value.copy(serverUrl = v, error = null) }
|
||||
fun onUsernameChange(v: String) { _state.value = _state.value.copy(username = v, error = null) }
|
||||
fun onPasswordChange(v: String) { _state.value = _state.value.copy(appPassword = v, error = null) }
|
||||
|
||||
fun submit() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.appPassword.isBlank()) {
|
||||
_state.value = s.copy(error = "All fields are required")
|
||||
return
|
||||
}
|
||||
_state.value = s.copy(loading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Cleartext traffic is disabled app-wide (see AndroidManifest usesCleartextTraffic),
|
||||
// so always force https regardless of what scheme (if any) the user typed.
|
||||
val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://")
|
||||
repo.login(normalized, s.username, s.appPassword)
|
||||
repo.fullSync()
|
||||
_state.value = _state.value.copy(loading = false, success = true)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalLogin", "Login/sync failed", e)
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = "Couldn't connect: ${describe(e)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns common low-level exceptions (which often have a null .message) into something readable. */
|
||||
private fun describe(e: Exception): String = when (e) {
|
||||
is java.net.UnknownHostException -> "Can't resolve that host \u2014 check the server URL and your network/DNS"
|
||||
is java.net.ConnectException -> "Connection refused \u2014 is the server reachable from this device/emulator?"
|
||||
is java.net.SocketTimeoutException -> "Connection timed out \u2014 check the URL and that the server is reachable"
|
||||
is javax.net.ssl.SSLHandshakeException -> "TLS/certificate error \u2014 self-signed certs aren't trusted by default (${e.message ?: e.javaClass.simpleName})"
|
||||
else -> e.message ?: "${e.javaClass.simpleName} (see Logcat tag NCalLogin for details)"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
package com.homelab.ncal.ui.month
|
||||
|
||||
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.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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.util.WeekBar
|
||||
import com.homelab.ncal.util.lanesForWeek
|
||||
import com.homelab.ncal.util.weeksForMonth
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthScreen(
|
||||
viewModel: MonthViewModel,
|
||||
onOpenItem: (CalendarItem) -> Unit,
|
||||
onNewEvent: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(state.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + " " + state.month.year) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::goToToday) { Icon(Icons.Filled.Today, "Today") }
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") }
|
||||
}
|
||||
) { padding ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.refreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
modifier = Modifier.padding(padding).fillMaxSize()
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
state.error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
MonthHeader(onPrev = viewModel::prevMonth, onNext = viewModel::nextMonth)
|
||||
WeekdayLabels()
|
||||
MonthGrid(
|
||||
month = state.month,
|
||||
selectedDay = state.selectedDay,
|
||||
itemsByDay = state.itemsByDay,
|
||||
onDayClick = viewModel::selectDay,
|
||||
onItemClick = onOpenItem,
|
||||
onToggleTask = viewModel::toggleTask
|
||||
)
|
||||
HorizontalDivider()
|
||||
DayAgenda(
|
||||
day = state.selectedDay,
|
||||
items = state.itemsByDay[state.selectedDay].orEmpty(),
|
||||
onOpenItem = onOpenItem,
|
||||
onToggleTask = viewModel::toggleTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(onPrev: () -> Unit, onNext: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onPrev) { Icon(Icons.Filled.ChevronLeft, "Previous month") }
|
||||
IconButton(onClick = onNext) { Icon(Icons.Filled.ChevronRight, "Next month") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayLabels() {
|
||||
val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek
|
||||
val days = (0..6).map { firstDow.plus(it.toLong()) }
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp)) {
|
||||
days.forEach { dow ->
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
dow.getDisplayName(TextStyle.SHORT, Locale.getDefault()),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Grid: one Column of week rows, each week a day-number strip + stacked event-bar lanes ----------
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
month: YearMonth,
|
||||
selectedDay: LocalDate,
|
||||
itemsByDay: Map<LocalDate, List<CalendarItem>>,
|
||||
onDayClick: (LocalDate) -> Unit,
|
||||
onItemClick: (CalendarItem) -> Unit,
|
||||
onToggleTask: (CalendarItem) -> Unit
|
||||
) {
|
||||
val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek
|
||||
val weeks = weeksForMonth(month, firstDow)
|
||||
val today = LocalDate.now()
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
weeks.forEach { week ->
|
||||
WeekRow(
|
||||
week = week,
|
||||
month = month,
|
||||
itemsByDay = itemsByDay,
|
||||
selectedDay = selectedDay,
|
||||
today = today,
|
||||
onDayClick = onDayClick,
|
||||
onItemClick = onItemClick,
|
||||
onToggleTask = onToggleTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
week: List<LocalDate>,
|
||||
month: YearMonth,
|
||||
itemsByDay: Map<LocalDate, List<CalendarItem>>,
|
||||
selectedDay: LocalDate,
|
||||
today: LocalDate,
|
||||
onDayClick: (LocalDate) -> Unit,
|
||||
onItemClick: (CalendarItem) -> Unit,
|
||||
onToggleTask: (CalendarItem) -> Unit
|
||||
) {
|
||||
val lanes = lanesForWeek(week, itemsByDay)
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.forEach { date ->
|
||||
val inMonth = YearMonth.from(date) == month
|
||||
val isSelected = date == selectedDay
|
||||
val isToday = date == today
|
||||
Box(
|
||||
Modifier.weight(1f).clickable { onDayClick(date) }.padding(vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(24.dp).clip(CircleShape).background(
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primary
|
||||
isToday -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> Color.Transparent
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
date.dayOfMonth.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimary
|
||||
!inMonth -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lanes.forEach { lane ->
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp)) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val bar = lane.firstOrNull { it.startCol == col }
|
||||
if (bar != null) {
|
||||
GridItemRow(
|
||||
item = bar.item,
|
||||
modifier = Modifier.weight(bar.span.toFloat()).padding(horizontal = 1.dp),
|
||||
onClick = { onItemClick(bar.item) },
|
||||
onToggleTask = { onToggleTask(bar.item) }
|
||||
)
|
||||
col += bar.span
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events render as a solid colored pill (they occupy time / span days). Tasks render as
|
||||
* plain text with a small leading dot - no colored background - matching how Nextcloud/
|
||||
* Google Calendar visually distinguish "things with duration" from "things to check off".
|
||||
* A completed task drops the dot entirely and just shows dimmed, struck-through text.
|
||||
*/
|
||||
@Composable
|
||||
private fun GridItemRow(
|
||||
item: CalendarItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onToggleTask: () -> Unit
|
||||
) {
|
||||
if (item.type == ItemType.TASK) {
|
||||
Row(
|
||||
modifier
|
||||
.height(18.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (!item.completed) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(5.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.tertiary)
|
||||
.clickable(onClick = onToggleTask)
|
||||
)
|
||||
Spacer(Modifier.size(3.dp))
|
||||
}
|
||||
Text(
|
||||
item.summary,
|
||||
color = if (item.completed) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (item.completed) TextDecoration.LineThrough else null,
|
||||
modifier = Modifier.clickable(onClick = onToggleTask)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier
|
||||
.height(18.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = if (item.completed) 0.45f else 1f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
item.summary,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (item.completed) TextDecoration.LineThrough else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Selected-day agenda strip below the grid ----------
|
||||
|
||||
@Composable
|
||||
private fun DayAgenda(
|
||||
day: LocalDate,
|
||||
items: List<CalendarItem>,
|
||||
onOpenItem: (CalendarItem) -> Unit,
|
||||
onToggleTask: (CalendarItem) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
if (items.isEmpty()) {
|
||||
Text(
|
||||
"Nothing scheduled",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
items.forEach { item ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onOpenItem(item) },
|
||||
headlineContent = {
|
||||
Text(item.summary, textDecoration = if (item.completed) TextDecoration.LineThrough else null)
|
||||
},
|
||||
supportingContent = {
|
||||
val time = item.start?.let { formatTime(it, item.allDay) }
|
||||
?: item.due?.let { "Due " + formatTime(it, false) }
|
||||
time?.let { Text(it) }
|
||||
},
|
||||
leadingContent = {
|
||||
if (item.type == ItemType.TASK) {
|
||||
Icon(
|
||||
imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = "Toggle complete",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onToggleTask(item) }
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Filled.Event, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long, allDay: Boolean): String {
|
||||
val zone = java.time.ZoneId.systemDefault()
|
||||
val instant = java.time.Instant.ofEpochMilli(millis)
|
||||
return if (allDay) "All day" else instant.atZone(zone).format(DateTimeFormatter.ofPattern("h:mm a"))
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.homelab.ncal.ui.month
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import com.homelab.ncal.util.groupByAllOccupiedDays
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
|
||||
data class MonthUiState(
|
||||
val month: YearMonth = YearMonth.now(),
|
||||
val selectedDay: LocalDate = LocalDate.now(),
|
||||
val itemsByDay: Map<LocalDate, List<CalendarItem>> = emptyMap(),
|
||||
val refreshing: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class MonthViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||
|
||||
private val month = MutableStateFlow(YearMonth.now())
|
||||
private val selectedDay = MutableStateFlow(LocalDate.now())
|
||||
private val refreshing = MutableStateFlow(false)
|
||||
private val error = MutableStateFlow<String?>(null)
|
||||
|
||||
private val zone = ZoneId.systemDefault()
|
||||
|
||||
private val allItems = repo.observeCollections()
|
||||
.flatMapLatest { collections ->
|
||||
val urls = collections.filter { it.enabled }.map { it.url }
|
||||
repo.observeItems(urls)
|
||||
}
|
||||
|
||||
val state: StateFlow<MonthUiState> = combine(allItems, month, selectedDay, refreshing, error) { items, m, day, r, e ->
|
||||
MonthUiState(month = m, selectedDay = day, itemsByDay = items.groupByAllOccupiedDays(zone), refreshing = r, error = e)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MonthUiState())
|
||||
|
||||
fun nextMonth() { month.value = month.value.plusMonths(1) }
|
||||
fun prevMonth() { month.value = month.value.minusMonths(1) }
|
||||
fun goToToday() {
|
||||
month.value = YearMonth.now()
|
||||
selectedDay.value = LocalDate.now()
|
||||
}
|
||||
fun selectDay(day: LocalDate) {
|
||||
selectedDay.value = day
|
||||
if (YearMonth.from(day) != month.value) month.value = YearMonth.from(day)
|
||||
}
|
||||
|
||||
fun toggleTask(item: CalendarItem) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.toggleTaskComplete(item)
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "toggleTask failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||
error.value = e.toUserMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshing.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.fullSync()
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "Month fullSync failed", e)
|
||||
error.value = e.toUserMessage()
|
||||
}
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.homelab.ncal.ui.nav
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.ui.NcalViewModelFactory
|
||||
import com.homelab.ncal.ui.agenda.AgendaScreen
|
||||
import com.homelab.ncal.ui.agenda.AgendaViewModel
|
||||
import com.homelab.ncal.ui.collections.CollectionsScreen
|
||||
import com.homelab.ncal.ui.collections.CollectionsViewModel
|
||||
import com.homelab.ncal.ui.detail.ItemEditScreen
|
||||
import com.homelab.ncal.ui.detail.ItemEditViewModel
|
||||
import com.homelab.ncal.ui.login.LoginScreen
|
||||
import com.homelab.ncal.ui.login.LoginViewModel
|
||||
import com.homelab.ncal.ui.month.MonthScreen
|
||||
import com.homelab.ncal.ui.month.MonthViewModel
|
||||
import com.homelab.ncal.ui.tasks.TasksScreen
|
||||
import com.homelab.ncal.ui.tasks.TasksViewModel
|
||||
import java.net.URLEncoder
|
||||
|
||||
private object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val AGENDA = "agenda"
|
||||
const val TASKS = "tasks"
|
||||
const val MONTH = "month"
|
||||
const val COLLECTIONS = "collections"
|
||||
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
|
||||
}
|
||||
|
||||
private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String {
|
||||
val encodedHref = URLEncoder.encode(href ?: "new", "UTF-8")
|
||||
val encodedCalUrl = URLEncoder.encode(calendarUrl ?: "none", "UTF-8")
|
||||
return "edit/$type/$encodedHref/$encodedCalUrl"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NcalNavGraph(
|
||||
app: NcalApplication,
|
||||
pendingDeepLink: Pair<String, String>? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {}
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val startDestination = if (app.repository.isLoggedIn) Routes.AGENDA else Routes.LOGIN
|
||||
|
||||
LaunchedEffect(pendingDeepLink) {
|
||||
pendingDeepLink?.let { (type, href) ->
|
||||
navController.navigate(editRoute(type, href))
|
||||
onDeepLinkConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
|
||||
composable(Routes.LOGIN) {
|
||||
val vm: LoginViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> LoginViewModel(repo) })
|
||||
LoginScreen(
|
||||
viewModel = vm,
|
||||
onLoggedIn = {
|
||||
navController.navigate(Routes.AGENDA) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.AGENDA) {
|
||||
val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) })
|
||||
AgendaScreen(
|
||||
viewModel = vm,
|
||||
onOpenItem = { item: CalendarItem ->
|
||||
navController.navigate(editRoute(item.type.name, item.href))
|
||||
},
|
||||
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
|
||||
onOpenTasks = { navController.navigate(Routes.TASKS) },
|
||||
onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
|
||||
onOpenMonth = { navController.navigate(Routes.MONTH) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MONTH) {
|
||||
val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) })
|
||||
MonthScreen(
|
||||
viewModel = vm,
|
||||
onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
|
||||
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.TASKS) {
|
||||
val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
|
||||
TasksScreen(
|
||||
viewModel = vm,
|
||||
onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) },
|
||||
onNewTask = { navController.navigate(editRoute("TASK", null)) },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.COLLECTIONS) {
|
||||
val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) })
|
||||
CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.EDIT,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("href") { type = NavType.StringType },
|
||||
navArgument("calendarUrl") { type = NavType.StringType }
|
||||
)
|
||||
) {
|
||||
val vm: ItemEditViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, handle -> ItemEditViewModel(repo, handle) })
|
||||
ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.homelab.ncal.ui.tasks
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.util.priorityColor
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TasksScreen(
|
||||
viewModel: TasksViewModel,
|
||||
onOpenTask: (CalendarItem) -> Unit,
|
||||
onNewTask: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Tasks") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } }
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onNewTask) { Icon(Icons.Filled.Add, "New task") }
|
||||
}
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding).fillMaxSize()) {
|
||||
state.error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.refreshing,
|
||||
onRefresh = viewModel::refresh,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
LazyColumn {
|
||||
items(state.tasks, key = { it.item.href.ifEmpty { it.item.uid } }) { node ->
|
||||
val task = node.item
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.padding(start = (node.depth * 24).dp)
|
||||
.clickable { onOpenTask(task) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
task.summary,
|
||||
textDecoration = if (task.completed) TextDecoration.LineThrough else null
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
task.due?.let { Text("Due " + it.toFormattedDate()) }
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = if (task.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
|
||||
contentDescription = "Toggle complete",
|
||||
tint = if (task.completed) MaterialTheme.colorScheme.primary else (priorityColor(task.priority) ?: MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.clickable { viewModel.toggle(task) }
|
||||
)
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toFormattedDate(): String =
|
||||
Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("MMM d, h:mm a"))
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.homelab.ncal.ui.tasks
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||
import com.homelab.ncal.util.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A task plus how many levels deep it is nested under its ancestors (0 = top-level). */
|
||||
data class TaskNode(val item: CalendarItem, val depth: Int)
|
||||
|
||||
data class TasksUiState(
|
||||
val tasks: List<TaskNode> = emptyList(),
|
||||
val refreshing: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class TasksViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||
|
||||
private val refreshing = MutableStateFlow(false)
|
||||
private val error = MutableStateFlow<String?>(null)
|
||||
|
||||
private val tasks = repo.observeCollections()
|
||||
.flatMapLatest { collections ->
|
||||
val urls = collections.filter { it.enabled }.map { it.url }
|
||||
repo.observeTasks(urls)
|
||||
}
|
||||
|
||||
val state: StateFlow<TasksUiState> = combine(tasks, refreshing, error) { taskList, r, e ->
|
||||
TasksUiState(buildTree(taskList), r, e)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TasksUiState())
|
||||
|
||||
/** Orders tasks depth-first under their parent (via [CalendarItem.parentUid]), preserving each
|
||||
* level's original sort order. Broken/cyclic parent references fall back to top-level. */
|
||||
private fun buildTree(tasks: List<CalendarItem>): List<TaskNode> {
|
||||
val allUids = tasks.map { it.uid }.toSet()
|
||||
val childrenByParent = tasks.groupBy { it.parentUid }
|
||||
val visited = mutableSetOf<String>()
|
||||
val result = mutableListOf<TaskNode>()
|
||||
|
||||
fun addWithChildren(task: CalendarItem, depth: Int) {
|
||||
if (!visited.add(task.uid)) return
|
||||
result += TaskNode(task, depth)
|
||||
childrenByParent[task.uid].orEmpty().forEach { addWithChildren(it, depth + 1) }
|
||||
}
|
||||
|
||||
tasks.forEach { task ->
|
||||
val isRoot = task.parentUid == null || task.parentUid !in allUids
|
||||
if (isRoot) addWithChildren(task, 0)
|
||||
}
|
||||
tasks.forEach { task -> if (task.uid !in visited) addWithChildren(task, 0) }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun toggle(item: CalendarItem) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.toggleTaskComplete(item)
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "toggle failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||
error.value = e.toUserMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshing.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.syncItems()
|
||||
error.value = null
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NCalError", "Tasks syncItems failed", e)
|
||||
error.value = e.toUserMessage()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.homelab.ncal.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val NcalBlue = Color(0xFF1F6FEB)
|
||||
|
||||
private val LightColors = lightColorScheme(primary = NcalBlue)
|
||||
private val DarkColors = darkColorScheme(primary = Color(0xFF6EA8FE))
|
||||
|
||||
@Composable
|
||||
fun NCalTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* The inclusive [LocalDate] range this item occupies on a calendar grid, in the device's
|
||||
* local time zone. Multi-day all-day events store DTEND as the day *after* the last day
|
||||
* per the iCalendar spec (an exclusive end) - this accounts for that so a 3-night trip
|
||||
* shows on all 3 days, not just the day after it ends.
|
||||
*/
|
||||
fun CalendarItem.dateRange(zone: ZoneId = ZoneId.systemDefault()): ClosedRange<LocalDate>? {
|
||||
val startMillis = start ?: due ?: return null
|
||||
val startDate = Instant.ofEpochMilli(startMillis).atZone(zone).toLocalDate()
|
||||
val endDate = end?.let {
|
||||
val e = Instant.ofEpochMilli(it).atZone(zone).toLocalDate()
|
||||
if (allDay && e.isAfter(startDate)) e.minusDays(1) else e
|
||||
} ?: startDate
|
||||
return startDate..maxOf(startDate, endDate)
|
||||
}
|
||||
|
||||
/** Groups items by every day they occupy (not just their start day). */
|
||||
fun List<CalendarItem>.groupByAllOccupiedDays(zone: ZoneId = ZoneId.systemDefault()): Map<LocalDate, List<CalendarItem>> {
|
||||
val result = mutableMapOf<LocalDate, MutableList<CalendarItem>>()
|
||||
for (item in this) {
|
||||
val range = item.dateRange(zone) ?: continue
|
||||
var d = range.start
|
||||
var guard = 0
|
||||
while (!d.isAfter(range.endInclusive) && guard++ < 400) {
|
||||
result.getOrPut(d) { mutableListOf() }.add(item)
|
||||
d = d.plusDays(1)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.network.CalDavException
|
||||
|
||||
/**
|
||||
* Short, user-facing summary for an error banner. [CalDavException] messages carry the full
|
||||
* request URL and up to 300 chars of raw server response (useful in logs, but it reads like a
|
||||
* crash dump if shown directly in the UI) - this collapses that down to one line.
|
||||
*/
|
||||
fun Throwable.toUserMessage(): String = when (this) {
|
||||
is CalDavException -> when (httpCode) {
|
||||
404 -> "Server couldn't find that item - it may have changed or been removed elsewhere."
|
||||
401 -> "Login rejected - your stored credentials may have expired. Try logging in again."
|
||||
403 -> "Server refused that change - this calendar may not allow it."
|
||||
in 500..599 -> "Server error ($httpCode). Try again later."
|
||||
null -> "Couldn't reach the server. Check your connection."
|
||||
else -> "Server error ($httpCode)."
|
||||
}
|
||||
else -> message?.takeIf { it.isNotBlank() } ?: "Something went wrong."
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import biweekly.Biweekly
|
||||
import biweekly.ICalendar
|
||||
import biweekly.component.VAlarm
|
||||
import biweekly.component.VEvent
|
||||
import biweekly.component.VTodo
|
||||
import biweekly.parameter.Related
|
||||
import biweekly.property.RelatedTo
|
||||
import biweekly.property.Status
|
||||
import biweekly.property.Trigger
|
||||
import biweekly.util.Duration
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.data.model.TaskStatus
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
object IcsMapper {
|
||||
|
||||
private fun Status?.toTaskStatus(): TaskStatus = when {
|
||||
this == null -> TaskStatus.NEEDS_ACTION
|
||||
isCompleted -> TaskStatus.COMPLETED
|
||||
isCancelled -> TaskStatus.CANCELLED
|
||||
isInProgress -> TaskStatus.IN_PROCESS
|
||||
else -> TaskStatus.NEEDS_ACTION
|
||||
}
|
||||
|
||||
private fun TaskStatus.toIcsStatus(): Status = when (this) {
|
||||
TaskStatus.NEEDS_ACTION -> Status.needsAction()
|
||||
TaskStatus.IN_PROCESS -> Status.inProgress()
|
||||
TaskStatus.COMPLETED -> Status.completed()
|
||||
TaskStatus.CANCELLED -> Status.cancelled()
|
||||
}
|
||||
|
||||
/** Reads the "minutes before" values out of any duration-relative VALARMs on the component. */
|
||||
private fun alarmMinutesBefore(alarms: List<VAlarm>): List<Int> =
|
||||
alarms.mapNotNull { alarm ->
|
||||
val duration = alarm.trigger?.duration ?: return@mapNotNull null
|
||||
if (!duration.isPrior) return@mapNotNull null
|
||||
(-duration.toMillis() / 60_000L).toInt()
|
||||
}.distinct().sorted()
|
||||
|
||||
/** Parses a raw .ics blob (one VEVENT or VTODO) fetched from the server into our model. */
|
||||
fun parse(ics: String, href: String, etag: String?, calendarUrl: String): CalendarItem? {
|
||||
val ical = Biweekly.parse(ics).first() ?: return null
|
||||
|
||||
ical.events.firstOrNull()?.let { ev ->
|
||||
val originalStart = ev.dateStart?.value?.time
|
||||
val originalEnd = ev.dateEnd?.value?.time
|
||||
val durationMillis = if (originalStart != null && originalEnd != null) originalEnd - originalStart else null
|
||||
|
||||
val recurrence = ev.recurrenceRule?.value
|
||||
val (resolvedStart, resolvedEnd) = if (recurrence != null && originalStart != null) {
|
||||
val next = RecurrenceUtils.nextOccurrence(originalStart, recurrence, System.currentTimeMillis())
|
||||
if (next == null) {
|
||||
android.util.Log.d("NCalParse", "EVENT '${ev.summary?.value}' recurrence ended (freq=${recurrence.frequency}, until=${recurrence.until}, count=${recurrence.count}) - dropping")
|
||||
return null
|
||||
}
|
||||
next to (durationMillis?.let { next + it } ?: next)
|
||||
} else {
|
||||
originalStart to originalEnd
|
||||
}
|
||||
|
||||
android.util.Log.d(
|
||||
"NCalParse",
|
||||
"EVENT '${ev.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " +
|
||||
"originalStart=${originalStart?.let { java.util.Date(it) }} resolvedStart=${resolvedStart?.let { java.util.Date(it) }}"
|
||||
)
|
||||
|
||||
return CalendarItem(
|
||||
uid = ev.uid?.value ?: UUID.randomUUID().toString(),
|
||||
href = href,
|
||||
etag = etag,
|
||||
calendarUrl = calendarUrl,
|
||||
type = ItemType.EVENT,
|
||||
summary = ev.summary?.value ?: "(no title)",
|
||||
description = ev.description?.value ?: "",
|
||||
location = ev.location?.value ?: "",
|
||||
url = ev.url?.value ?: "",
|
||||
start = resolvedStart,
|
||||
end = resolvedEnd,
|
||||
allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
|
||||
priority = ev.priority?.value ?: 0,
|
||||
reminderMinutes = alarmMinutesBefore(ev.alarms),
|
||||
rawIcs = ics
|
||||
)
|
||||
}
|
||||
|
||||
ical.todos.firstOrNull()?.let { td ->
|
||||
val originalDue = td.dateDue?.value?.time
|
||||
val originalStart = td.dateStart?.value?.time
|
||||
val startToDueMillis = if (originalStart != null && originalDue != null) originalDue - originalStart else null
|
||||
|
||||
val recurrence = td.recurrenceRule?.value
|
||||
val resolvedDue = if (recurrence != null && originalDue != null) {
|
||||
RecurrenceUtils.nextOccurrence(originalDue, recurrence, System.currentTimeMillis())
|
||||
} else {
|
||||
originalDue
|
||||
}
|
||||
val resolvedStart = if (recurrence != null && originalStart != null && resolvedDue != null) {
|
||||
startToDueMillis?.let { resolvedDue - it }
|
||||
} else {
|
||||
originalStart
|
||||
}
|
||||
|
||||
android.util.Log.d(
|
||||
"NCalParse",
|
||||
"TASK '${td.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " +
|
||||
"originalDue=${originalDue?.let { java.util.Date(it) }} resolvedDue=${resolvedDue?.let { java.util.Date(it) }} " +
|
||||
"originalStart=${originalStart?.let { java.util.Date(it) }} completed=${td.status?.isCompleted}"
|
||||
)
|
||||
|
||||
val status = td.status.toTaskStatus()
|
||||
val percentComplete = td.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
|
||||
|
||||
return CalendarItem(
|
||||
uid = td.uid?.value ?: UUID.randomUUID().toString(),
|
||||
href = href,
|
||||
etag = etag,
|
||||
calendarUrl = calendarUrl,
|
||||
type = ItemType.TASK,
|
||||
summary = td.summary?.value ?: "(no title)",
|
||||
description = td.description?.value ?: "",
|
||||
location = td.location?.value ?: "",
|
||||
url = td.url?.value ?: "",
|
||||
start = resolvedStart,
|
||||
due = resolvedDue,
|
||||
completed = status == TaskStatus.COMPLETED || percentComplete >= 100,
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
priority = td.priority?.value ?: 0,
|
||||
parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
|
||||
reminderMinutes = alarmMinutesBefore(td.alarms),
|
||||
rawIcs = ics
|
||||
)
|
||||
}
|
||||
|
||||
android.util.Log.d("NCalParse", "FAILED to parse item at $href in calendar $calendarUrl - no VEVENT or VTODO found. Raw ics (first 300 chars): ${ics.take(300)}")
|
||||
return null
|
||||
}
|
||||
|
||||
/** Builds a full .ics document ready to PUT to the server for the given item. */
|
||||
fun toIcs(item: CalendarItem): String {
|
||||
val ical = ICalendar()
|
||||
when (item.type) {
|
||||
ItemType.EVENT -> {
|
||||
val ev = VEvent()
|
||||
ev.setUid(item.uid)
|
||||
ev.setSummary(item.summary)
|
||||
if (item.description.isNotBlank()) ev.setDescription(item.description)
|
||||
if (item.location.isNotBlank()) ev.setLocation(item.location)
|
||||
if (item.url.isNotBlank()) ev.setUrl(item.url)
|
||||
item.start?.let { ev.setDateStart(Date(it), !item.allDay) }
|
||||
item.end?.let { ev.setDateEnd(Date(it), !item.allDay) }
|
||||
if (item.priority > 0) ev.setPriority(item.priority)
|
||||
item.reminderMinutes.forEach { minutes ->
|
||||
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
||||
ev.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary))
|
||||
}
|
||||
ical.addEvent(ev)
|
||||
}
|
||||
ItemType.TASK -> {
|
||||
val td = VTodo()
|
||||
td.setUid(item.uid)
|
||||
td.setSummary(item.summary)
|
||||
if (item.description.isNotBlank()) td.setDescription(item.description)
|
||||
if (item.location.isNotBlank()) td.setLocation(item.location)
|
||||
if (item.url.isNotBlank()) td.setUrl(item.url)
|
||||
item.start?.let { td.setDateStart(Date(it)) }
|
||||
item.due?.let { td.setDateDue(Date(it)) }
|
||||
if (item.priority > 0) td.setPriority(item.priority)
|
||||
td.setStatus(item.status.toIcsStatus())
|
||||
if (item.percentComplete > 0) td.setPercentComplete(item.percentComplete)
|
||||
item.parentUid?.takeIf { it.isNotBlank() }?.let { td.addRelatedTo(RelatedTo(it)) }
|
||||
if (item.due != null) {
|
||||
item.reminderMinutes.forEach { minutes ->
|
||||
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
||||
td.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary))
|
||||
}
|
||||
}
|
||||
ical.addTodo(td)
|
||||
}
|
||||
}
|
||||
return Biweekly.write(ical).go()
|
||||
}
|
||||
|
||||
/** Crude but effective: DTSTART/DTEND with a VALUE=DATE param (no time) means an all-day item. */
|
||||
private fun hasTimeComponent(ics: String, property: String): Boolean {
|
||||
val line = ics.lines().firstOrNull { it.startsWith(property) } ?: return true
|
||||
return !line.contains("VALUE=DATE")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Shared month-grid layout algorithm used by both the in-app Month screen and the home
|
||||
* screen widget, so the two render identical week rows and event-bar packing.
|
||||
*/
|
||||
const val MAX_LANES_PER_WEEK = 4
|
||||
|
||||
fun weeksForMonth(month: YearMonth, firstDow: DayOfWeek): List<List<LocalDate>> {
|
||||
val firstOfMonth = month.atDay(1)
|
||||
val leadingBlanks = ((firstOfMonth.dayOfWeek.value - firstDow.value) + 7) % 7
|
||||
val gridStart = firstOfMonth.minusDays(leadingBlanks.toLong())
|
||||
val totalCells = leadingBlanks + month.lengthOfMonth()
|
||||
val rows = (totalCells + 6) / 7
|
||||
return (0 until rows).map { row -> (0 until 7).map { col -> gridStart.plusDays((row * 7 + col).toLong()) } }
|
||||
}
|
||||
|
||||
data class WeekBar(val item: CalendarItem, val startCol: Int, val span: Int)
|
||||
|
||||
fun barFor(item: CalendarItem, week: List<LocalDate>): WeekBar? {
|
||||
val range = item.dateRange() ?: return null
|
||||
val weekStart = week.first()
|
||||
val weekEnd = week.last()
|
||||
if (range.endInclusive.isBefore(weekStart) || range.start.isAfter(weekEnd)) return null
|
||||
val clippedStart = maxOf(range.start, weekStart)
|
||||
val clippedEnd = minOf(range.endInclusive, weekEnd)
|
||||
val startCol = ChronoUnit.DAYS.between(weekStart, clippedStart).toInt()
|
||||
val endCol = ChronoUnit.DAYS.between(weekStart, clippedEnd).toInt()
|
||||
return WeekBar(item, startCol, endCol - startCol + 1)
|
||||
}
|
||||
|
||||
/** Greedy interval packing: assign each bar to the first lane whose last-occupied column has passed. */
|
||||
fun packLanes(bars: List<WeekBar>, maxLanes: Int): List<List<WeekBar>> {
|
||||
val lanes = mutableListOf<MutableList<WeekBar>>()
|
||||
val laneEnd = mutableListOf<Int>()
|
||||
for (bar in bars) {
|
||||
var placed = false
|
||||
for (i in lanes.indices) {
|
||||
if (laneEnd[i] < bar.startCol) {
|
||||
lanes[i].add(bar)
|
||||
laneEnd[i] = bar.startCol + bar.span - 1
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!placed && lanes.size < maxLanes) {
|
||||
lanes.add(mutableListOf(bar))
|
||||
laneEnd.add(bar.startCol + bar.span - 1)
|
||||
}
|
||||
// If every lane is full, this bar is silently dropped from the grid for that week -
|
||||
// it's still visible from the day-agenda list below, just not as a chip up top.
|
||||
}
|
||||
return lanes
|
||||
}
|
||||
|
||||
/** Bars + packed lanes for a single week, ready to render. */
|
||||
fun lanesForWeek(week: List<LocalDate>, itemsByDay: Map<LocalDate, List<CalendarItem>>, maxLanes: Int = MAX_LANES_PER_WEEK): List<List<WeekBar>> {
|
||||
val itemsThisWeek = week.flatMap { itemsByDay[it].orEmpty() }.distinctBy { it.href.ifEmpty { it.uid } }
|
||||
val bars = itemsThisWeek.mapNotNull { barFor(it, week) }
|
||||
.sortedWith(compareBy({ it.startCol }, { -it.span }))
|
||||
return packLanes(bars, maxLanes)
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* iCal PRIORITY (1-9) color convention specified for NCal: 1-4 is high/red, 5 is medium/yellow,
|
||||
* 6-9 is low/blue. 0 (undefined) has no color.
|
||||
*/
|
||||
fun priorityColor(priority: Int): Color? = when {
|
||||
priority in 1..4 -> Color(0xFFE53935)
|
||||
priority == 5 -> Color(0xFFF9A825)
|
||||
priority in 6..9 -> Color(0xFF1E88E5)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun priorityLabel(priority: Int): String = when {
|
||||
priority == 0 -> "None"
|
||||
priority in 1..4 -> "$priority – High"
|
||||
priority == 5 -> "5 – Medium"
|
||||
else -> "$priority – Low"
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.homelab.ncal.util
|
||||
|
||||
import biweekly.util.Frequency
|
||||
import biweekly.util.Recurrence
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Minimal RRULE evaluator. CalDAV servers return the *master* VEVENT/VTODO for a
|
||||
* recurring item (with its original, possibly long-past, DTSTART) - they don't expand
|
||||
* occurrences for us in the calendar-data payload. Without this, a weekly meeting or a
|
||||
* yearly birthday would show once, forever, on the date it was first created.
|
||||
*
|
||||
* This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
|
||||
* and UNTIL. It intentionally does not implement BYDAY/BYMONTH/BYSETPOS/etc - those cover
|
||||
* a small minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday").
|
||||
* Anything using those rule parts still round-trips correctly on save/edit (the raw ICS
|
||||
* is preserved via [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be
|
||||
* *re-dated* for display, and will show at its original occurrence like before.
|
||||
*/
|
||||
object RecurrenceUtils {
|
||||
|
||||
private const val MAX_ITERATIONS = 10_000
|
||||
|
||||
/**
|
||||
* Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis],
|
||||
* or null if the series has already ended (past UNTIL/COUNT) or uses an unsupported rule.
|
||||
*/
|
||||
fun nextOccurrence(originalStartMillis: Long, recurrence: Recurrence, referenceMillis: Long): Long? {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val freq = recurrence.frequency ?: return null
|
||||
if (freq !in SUPPORTED_FREQUENCIES) return originalStartMillis // fall back to showing the master as-is
|
||||
|
||||
val interval = (recurrence.interval ?: 1).coerceAtLeast(1)
|
||||
val until: ZonedDateTime? = recurrence.until?.let {
|
||||
Instant.ofEpochMilli(it.time).atZone(zone)
|
||||
}
|
||||
val maxCount = recurrence.count
|
||||
|
||||
var current = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
|
||||
val reference = Instant.ofEpochMilli(referenceMillis).atZone(zone)
|
||||
|
||||
if (!current.isBefore(reference)) return current.toInstant().toEpochMilli()
|
||||
|
||||
var occurrenceIndex = 1
|
||||
var iterations = 0
|
||||
while (current.isBefore(reference)) {
|
||||
if (iterations++ > MAX_ITERATIONS) return null
|
||||
|
||||
current = when (freq) {
|
||||
Frequency.DAILY -> current.plusDays(interval.toLong())
|
||||
Frequency.WEEKLY -> current.plusWeeks(interval.toLong())
|
||||
Frequency.MONTHLY -> current.plusMonths(interval.toLong())
|
||||
Frequency.YEARLY -> current.plusYears(interval.toLong())
|
||||
else -> return null
|
||||
}
|
||||
occurrenceIndex++
|
||||
|
||||
if (maxCount != null && occurrenceIndex > maxCount) return null
|
||||
if (until != null && current.isAfter(until)) return null
|
||||
}
|
||||
return current.toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
private val SUPPORTED_FREQUENCIES = setOf(Frequency.DAILY, Frequency.WEEKLY, Frequency.MONTHLY, Frequency.YEARLY)
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.Serializer
|
||||
import androidx.datastore.dataStoreFile
|
||||
import androidx.glance.state.GlanceStateDefinition
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
private const val KEYSTORE_ALIAS = "ncal_widget_state_key"
|
||||
private const val GCM_IV_LENGTH = 12
|
||||
private const val GCM_TAG_LENGTH_BITS = 128
|
||||
|
||||
private fun widgetStateKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getKey(KEYSTORE_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the widget's persisted state (currently just "which month is this instance
|
||||
* showing") at rest via an Android Keystore-backed AES key. The value itself is trivial -
|
||||
* an integer month offset, no calendar content - but the goal is nothing the app writes to
|
||||
* disk is left in plaintext, so it gets the same treatment as everything else.
|
||||
*/
|
||||
private object EncryptedIntSerializer : Serializer<Int> {
|
||||
override val defaultValue: Int = 0
|
||||
|
||||
override suspend fun readFrom(input: InputStream): Int {
|
||||
val bytes = input.readBytes()
|
||||
if (bytes.isEmpty()) return defaultValue
|
||||
return try {
|
||||
val iv = bytes.copyOfRange(0, GCM_IV_LENGTH)
|
||||
val cipherText = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, widgetStateKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
|
||||
String(cipher.doFinal(cipherText)).toInt()
|
||||
} catch (e: Exception) {
|
||||
throw CorruptionException("Could not decrypt widget state", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Int, output: OutputStream) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, widgetStateKey())
|
||||
val cipherText = cipher.doFinal(t.toString().toByteArray())
|
||||
output.write(cipher.iv)
|
||||
output.write(cipherText)
|
||||
}
|
||||
}
|
||||
|
||||
object EncryptedMonthOffsetStateDefinition : GlanceStateDefinition<Int> {
|
||||
private const val FILE_PREFIX = "widget_month_offset_"
|
||||
|
||||
override suspend fun getDataStore(context: Context, fileKey: String): DataStore<Int> =
|
||||
DataStoreFactory.create(serializer = EncryptedIntSerializer) { getLocation(context, fileKey) }
|
||||
|
||||
override fun getLocation(context: Context, fileKey: String): File =
|
||||
context.dataStoreFile("$FILE_PREFIX$fileKey")
|
||||
}
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextDecoration
|
||||
import androidx.glance.text.TextStyle
|
||||
import com.homelab.ncal.MainActivity
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.data.model.ItemType
|
||||
import com.homelab.ncal.util.groupByAllOccupiedDays
|
||||
import com.homelab.ncal.util.lanesForWeek
|
||||
import com.homelab.ncal.util.priorityColor
|
||||
import com.homelab.ncal.util.weeksForMonth
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Font/element sizes and how many event/task lanes to show per week, derived from the widget's
|
||||
* current on-screen size so a resized-larger widget actually uses the extra space instead of
|
||||
* showing the same small grid surrounded by blank margin.
|
||||
*/
|
||||
private data class MonthWidgetScale(
|
||||
val headerFontSize: TextUnit,
|
||||
val weekdayFontSize: TextUnit,
|
||||
val dayFontSize: TextUnit,
|
||||
val dayCircleSize: Dp,
|
||||
val itemFontSize: TextUnit,
|
||||
val laneHeight: Dp,
|
||||
val maxLanes: Int
|
||||
)
|
||||
|
||||
private fun monthWidgetScaleFor(size: DpSize): MonthWidgetScale {
|
||||
val compact = size.width < 280.dp
|
||||
val expanded = size.width >= 380.dp
|
||||
val maxLanes = when {
|
||||
size.height < 280.dp -> 2
|
||||
size.height >= 380.dp -> 4
|
||||
else -> 3
|
||||
}
|
||||
return when {
|
||||
compact -> MonthWidgetScale(14.sp, 10.sp, 11.sp, 20.dp, 10.sp, 16.dp, maxLanes)
|
||||
expanded -> MonthWidgetScale(18.sp, 13.sp, 15.sp, 28.dp, 13.sp, 20.dp, maxLanes)
|
||||
else -> MonthWidgetScale(16.sp, 11.sp, 13.sp, 24.dp, 11.sp, 18.dp, maxLanes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home screen widget mirroring the in-app Month screen: a one-month grid with event pills
|
||||
* and task dots visible on each day. Reuses the exact same [weeksForMonth]/[lanesForWeek]
|
||||
* layout algorithm as [com.homelab.ncal.ui.month.MonthScreen] so the two stay in sync.
|
||||
*
|
||||
* Glance's Row/Column only support equal-weight distribution (no proportional weights like
|
||||
* Compose's `Modifier.weight(n)`), so multi-day event bars are sized with explicit `Dp` widths
|
||||
* computed from [LocalSize] instead.
|
||||
*/
|
||||
class MonthGridWidget : GlanceAppWidget() {
|
||||
override val stateDefinition = EncryptedMonthOffsetStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val offset = getAppWidgetState(context, EncryptedMonthOffsetStateDefinition, id)
|
||||
val month = YearMonth.now().plusMonths(offset.toLong())
|
||||
|
||||
val repo = (context.applicationContext as NcalApplication).repository
|
||||
val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url }
|
||||
val itemsByDay = repo.observeItems(urls).first().groupByAllOccupiedDays(ZoneId.systemDefault())
|
||||
|
||||
provideContent {
|
||||
MonthWidgetContent(month = month, itemsByDay = itemsByDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthWidgetContent(month: YearMonth, itemsByDay: Map<LocalDate, List<CalendarItem>>) {
|
||||
val context = LocalContext.current
|
||||
val scale = monthWidgetScaleFor(LocalSize.current)
|
||||
GlanceTheme {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.background)
|
||||
.clickable(actionStartActivity(Intent(context, MainActivity::class.java)))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
WidgetHeader(month, scale)
|
||||
WidgetWeekdayLabels(scale)
|
||||
WidgetMonthGrid(month, itemsByDay, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetHeader(month: YearMonth, scale: MonthWidgetScale) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"‹",
|
||||
modifier = GlanceModifier.clickable(actionRunCallback<PrevMonthAction>()).padding(8.dp),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = scale.headerFontSize)
|
||||
)
|
||||
Text(
|
||||
"${month.month.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())} ${month.year}",
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = scale.headerFontSize,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
Text(
|
||||
"›",
|
||||
modifier = GlanceModifier.clickable(actionRunCallback<NextMonthAction>()).padding(8.dp),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = scale.headerFontSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetWeekdayLabels(scale: MonthWidgetScale) {
|
||||
val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek
|
||||
val days = (0..6).map { firstDow.plus(it.toLong()) }
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
days.forEach { dow ->
|
||||
Box(modifier = GlanceModifier.defaultWeight(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
dow.getDisplayName(JavaTextStyle.SHORT, Locale.getDefault()),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = scale.weekdayFontSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetMonthGrid(month: YearMonth, itemsByDay: Map<LocalDate, List<CalendarItem>>, scale: MonthWidgetScale) {
|
||||
val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek
|
||||
val weeks = weeksForMonth(month, firstDow)
|
||||
val today = LocalDate.now()
|
||||
// Available width = widget width minus the outer 8.dp+8.dp padding applied in MonthWidgetContent.
|
||||
val columnWidth = (LocalSize.current.width - 16.dp) / 7
|
||||
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
// defaultWeight() requires a ColumnScope receiver, only available here (inside Column's
|
||||
// own content lambda) - computed here and passed down rather than built inside
|
||||
// WidgetWeekRow, which is a separate composable with no ColumnScope in lexical scope.
|
||||
weeks.forEach { week ->
|
||||
WidgetWeekRow(week, month, itemsByDay, today, columnWidth, scale, GlanceModifier.fillMaxWidth().defaultWeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetWeekRow(
|
||||
week: List<LocalDate>,
|
||||
month: YearMonth,
|
||||
itemsByDay: Map<LocalDate, List<CalendarItem>>,
|
||||
today: LocalDate,
|
||||
columnWidth: Dp,
|
||||
scale: MonthWidgetScale,
|
||||
modifier: GlanceModifier
|
||||
) {
|
||||
val lanes = lanesForWeek(week, itemsByDay, maxLanes = scale.maxLanes)
|
||||
val context = LocalContext.current
|
||||
|
||||
// Each week claims an equal share of any extra vertical room a resized-larger widget has -
|
||||
// without this, leftover space collects as one dead gap below the last week instead of
|
||||
// extra breathing room between weeks.
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.forEach { date ->
|
||||
val inMonth = YearMonth.from(date) == month
|
||||
val isToday = date == today
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(actionStartActivity(Intent(context, MainActivity::class.java))),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var circle = GlanceModifier.size(scale.dayCircleSize).cornerRadius(scale.dayCircleSize / 2)
|
||||
if (isToday) circle = circle.background(GlanceTheme.colors.primaryContainer)
|
||||
Box(modifier = circle, contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
date.dayOfMonth.toString(),
|
||||
style = TextStyle(
|
||||
color = if (!inMonth) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface,
|
||||
fontSize = scale.dayFontSize,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lanes.forEach { lane ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val bar = lane.firstOrNull { it.startCol == col }
|
||||
if (bar != null) {
|
||||
WidgetItemBar(item = bar.item, width = columnWidth * bar.span, scale = scale)
|
||||
col += bar.span
|
||||
} else {
|
||||
Spacer(modifier = GlanceModifier.width(columnWidth).height(scale.laneHeight))
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors MonthScreen's GridItemRow: events are a solid pill, tasks are a dot + text. */
|
||||
@Composable
|
||||
private fun WidgetItemBar(item: CalendarItem, width: Dp, scale: MonthWidgetScale) {
|
||||
val context = LocalContext.current
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name)
|
||||
putExtra(MainActivity.EXTRA_ITEM_HREF, item.href)
|
||||
}
|
||||
|
||||
if (item.type == ItemType.TASK) {
|
||||
// A Row's defaultWeight() doesn't reliably bound the Text's width inside a fixed-width
|
||||
// Row under Glance/RemoteViews, so stack dot+text in a Box (with fillMaxWidth on the
|
||||
// Text, reserving left padding for the dot) instead of laying them out side by side.
|
||||
// A `background` is also required for Glance/RemoteViews to actually enforce the exact
|
||||
// width below (without one, width() is silently ignored and content overflows) - use the
|
||||
// same color as the page background so it stays visually invisible, matching the app.
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(scale.laneHeight)
|
||||
.background(GlanceTheme.colors.background)
|
||||
.clickable(actionStartActivity(intent))
|
||||
.padding(horizontal = 2.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
item.summary,
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(start = if (!item.completed) 8.dp else 0.dp),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (item.completed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface,
|
||||
fontSize = scale.itemFontSize,
|
||||
textDecoration = if (item.completed) TextDecoration.LineThrough else TextDecoration.None
|
||||
)
|
||||
)
|
||||
if (!item.completed) {
|
||||
val dotColor = priorityColor(item.priority)
|
||||
var dot = GlanceModifier.size(5.dp).cornerRadius(3.dp)
|
||||
dot = if (dotColor != null) dot.background(dotColor) else dot.background(GlanceTheme.colors.tertiary)
|
||||
Box(modifier = dot) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(scale.laneHeight)
|
||||
.cornerRadius(4.dp)
|
||||
.background(GlanceTheme.colors.primary)
|
||||
.clickable(actionStartActivity(intent))
|
||||
.padding(horizontal = 3.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
item.summary,
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onPrimary,
|
||||
fontSize = scale.itemFontSize,
|
||||
textDecoration = if (item.completed) TextDecoration.LineThrough else TextDecoration.None
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class MonthGridWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = MonthGridWidget()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
|
||||
class PrevMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
updateAppWidgetState(context, EncryptedMonthOffsetStateDefinition, glanceId) { offset -> offset - 1 }
|
||||
MonthGridWidget().updateAll(context)
|
||||
}
|
||||
}
|
||||
|
||||
class NextMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
updateAppWidgetState(context, EncryptedMonthOffsetStateDefinition, glanceId) { offset -> offset + 1 }
|
||||
MonthGridWidget().updateAll(context)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import com.homelab.ncal.MainActivity
|
||||
import com.homelab.ncal.NcalApplication
|
||||
import com.homelab.ncal.data.model.CalendarItem
|
||||
import com.homelab.ncal.util.priorityColor
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
internal val taskHrefKey = ActionParameters.Key<String>("task_href")
|
||||
|
||||
/** Home screen widget listing incomplete tasks, grouped and ordered by due date - the widget
|
||||
* equivalent of the in-app Tasks list, minus the subtask tree (kept flat/chronological here
|
||||
* since the point of a widget is a quick "what's next" glance, not full hierarchy browsing). */
|
||||
class NextTasksWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val repo = (context.applicationContext as NcalApplication).repository
|
||||
val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url }
|
||||
val zone = ZoneId.systemDefault()
|
||||
|
||||
val groups = repo.observeTasks(urls).first()
|
||||
.filter { !it.completed }
|
||||
.sortedWith(compareBy({ it.due == null }, { it.due }))
|
||||
.groupBy { it.due?.let { d -> Instant.ofEpochMilli(d).atZone(zone).toLocalDate() } }
|
||||
.toList()
|
||||
.sortedWith(compareBy(nullsLast()) { it.first })
|
||||
|
||||
provideContent {
|
||||
NextTasksContent(groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>) {
|
||||
val context = LocalContext.current
|
||||
GlanceTheme {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.background)
|
||||
.clickable(actionStartActivity(Intent(context, MainActivity::class.java)))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Tasks",
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp),
|
||||
modifier = GlanceModifier.padding(bottom = 4.dp)
|
||||
)
|
||||
if (groups.isEmpty()) {
|
||||
Text(
|
||||
"Nothing due",
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
groups.forEach { (date, tasksForDate) ->
|
||||
item(itemId = date?.toEpochDay() ?: -1L) {
|
||||
Text(
|
||||
text = date?.let {
|
||||
it.format(DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()))
|
||||
} ?: "No due date",
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
modifier = GlanceModifier.padding(top = 6.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
items(tasksForDate, itemId = { it.href.hashCode().toLong() }) { task ->
|
||||
TaskRow(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(item: CalendarItem) {
|
||||
val context = LocalContext.current
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name)
|
||||
putExtra(MainActivity.EXTRA_ITEM_HREF, item.href)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.background(GlanceTheme.colors.background)
|
||||
.clickable(actionStartActivity(intent))
|
||||
.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(16.dp)
|
||||
.clickable(actionRunCallback<ToggleTaskAction>(actionParametersOf(taskHrefKey to item.href))),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val dotColor = priorityColor(item.priority)
|
||||
var dot = GlanceModifier.size(10.dp).cornerRadius(5.dp)
|
||||
dot = if (dotColor != null) dot.background(dotColor) else dot.background(GlanceTheme.colors.tertiary)
|
||||
Box(modifier = dot) {}
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.width(6.dp))
|
||||
Text(
|
||||
item.summary,
|
||||
modifier = GlanceModifier.defaultWeight().background(GlanceTheme.colors.background),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp)
|
||||
)
|
||||
item.due?.let { due ->
|
||||
Spacer(modifier = GlanceModifier.width(4.dp))
|
||||
Text(
|
||||
formatTime(due),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long): String =
|
||||
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault()))
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import com.homelab.ncal.NcalApplication
|
||||
|
||||
/** [com.homelab.ncal.data.repository.NextcloudRepository.save] already triggers a widget
|
||||
* refresh for every write, so toggling completion here doesn't need its own updateAll call. */
|
||||
class ToggleTaskAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val href = parameters[taskHrefKey] ?: return
|
||||
val repo = (context.applicationContext as NcalApplication).repository
|
||||
val item = repo.itemByHref(href) ?: return
|
||||
repo.toggleTaskComplete(item)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.homelab.ncal.widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
|
||||
class NextTasksWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = NextTasksWidget()
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,24 h48 a6,6 0 0 1 6,6 v48 a6,6 0 0 1 -6,6 h-48 a6,6 0 0 1 -6,-6 v-48 a6,6 0 0 1 6,-6 z M30,42 h48 M40,18 v12 M68,18 v12 M46,58 l8,8 l16,-16"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="4"
|
||||
android:fillType="evenOdd" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
</FrameLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<color name="ic_launcher_background">#1F6FEB</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<resources>
|
||||
<string name="app_name">NCal</string>
|
||||
<string name="month_grid_widget_label">Calendar Month</string>
|
||||
<string name="month_grid_widget_description">Shows the current month at a glance with your events.</string>
|
||||
<string name="next_tasks_widget_label">Tasks</string>
|
||||
<string name="next_tasks_widget_description">Shows your upcoming tasks.</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<style name="Theme.NCal" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:description="@string/month_grid_widget_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="180dp"
|
||||
android:minHeight="250dp"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:description="@string/next_tasks_widget_description"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.3.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
id("com.google.devtools.ksp") version "1.9.24-1.0.20" apply false
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
Binary file not shown.
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "NCal"
|
||||
include(":app")
|
||||
Loading…
Reference in New Issue