Compare commits
38 Commits
e0d1b24afc
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd347a07f1 | ||
|
|
cc51ede8a5 | ||
|
|
101370719b | ||
|
|
e72ffab4f4 | ||
|
|
3212fe2c6a | ||
|
|
e042f92ec6 | ||
|
|
749eccfaaa | ||
|
|
fd804000c5 | ||
|
|
fdad9c9ba4 | ||
|
|
1f90503104 | ||
|
|
486cc9c622 | ||
|
|
7dbaad4b00 | ||
|
|
5b5a5d5cd8 | ||
|
|
e49a2e027f | ||
|
|
8d7ac124ae | ||
|
|
bd3c92aa86 | ||
|
|
993d5d83e1 | ||
|
|
e9d58d8239 | ||
|
|
ca4c875659 | ||
|
|
80bdb91acf | ||
|
|
382cb717a4 | ||
|
|
a3568fb6f3 | ||
|
|
9dd41420b6 | ||
|
|
3137abcef2 | ||
|
|
d2d5e2b19c | ||
|
|
740367b0b9 | ||
|
|
9ced441935 | ||
|
|
701db45f8a | ||
|
|
9815866911 | ||
|
|
c6056fb7bd | ||
|
|
b172a8f055 | ||
|
|
e7bed5c5a8 | ||
|
|
6654012cbb | ||
|
|
807db6a57b | ||
|
|
644b532104 | ||
|
|
87fc8df146 | ||
|
|
7a9bdba557 | ||
|
|
3352aa3be7 |
1
.gitignore
vendored
@@ -38,6 +38,7 @@ captures/
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
80
FDROID.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Publishing Calendarr on F-Droid
|
||||
|
||||
Two independent channels — do both in parallel:
|
||||
|
||||
- **Own repo (live today):** you build + sign the APK, generate a small catalog,
|
||||
host it at a URL; users add that URL in the F-Droid app.
|
||||
- **Official F-Droid (~weeks to be accepted, then automatic):** F-Droid builds +
|
||||
signs from source and lists it in the catalog every F-Droid user already has.
|
||||
|
||||
The app is GPLv3 and has **no** non-free dependencies (no Play Services / Firebase),
|
||||
so it is F-Droid-eligible. Store-listing metadata lives in `fastlane/metadata/…`.
|
||||
|
||||
> ⚠️ Signatures differ between the two channels: your own repo serves APKs signed
|
||||
> with **your** keystore; the official build is signed with **F-Droid's** key. A
|
||||
> user can't seamlessly update from one to the other (reinstall once). To unify
|
||||
> later, set up *Reproducible Builds* so F-Droid ships your signature.
|
||||
|
||||
---
|
||||
|
||||
## A) Your own repo (fdroid.scarriffle.com)
|
||||
|
||||
Needs the `fdroidserver` tool + Android SDK build-tools (for `aapt`).
|
||||
|
||||
```bash
|
||||
# 1. Install the tool (macOS)
|
||||
brew install fdroidserver # or: pipx install fdroidserver
|
||||
|
||||
# 2. Build a SIGNED release APK (uses keystore.properties; F-Droid repos serve
|
||||
# APKs, not AABs)
|
||||
./gradlew assembleRelease
|
||||
# → app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
# 3. Create the repo (once). Generates config.yml + an index-signing key.
|
||||
mkdir -p ~/calendarr-fdroid && cd ~/calendarr-fdroid
|
||||
fdroid init
|
||||
|
||||
# 4. Drop the signed APK in and copy the store metadata
|
||||
cp "/path/to/Calendarr Android/app/build/outputs/apk/release/app-release.apk" repo/
|
||||
mkdir -p metadata
|
||||
# copy the fastlane texts/icons so descriptions show in the client:
|
||||
cp -r "/path/to/Calendarr Android/fastlane/metadata/android" metadata/com.scarriffle.calendarr
|
||||
|
||||
# 5. Generate/refresh the signed index
|
||||
fdroid update -c --pretty
|
||||
|
||||
# 6. Upload the whole ~/calendarr-fdroid/repo directory to your webserver at:
|
||||
# https://fdroid.scarriffle.com/fdroid/repo
|
||||
```
|
||||
|
||||
Users then open F-Droid → Settings → Repositories → **+** →
|
||||
`https://fdroid.scarriffle.com/fdroid/repo` → Calendarr appears and auto-updates.
|
||||
|
||||
**Each new release:** bump `versionCode`/`versionName` in `app/build.gradle.kts`,
|
||||
`./gradlew assembleRelease`, copy the new APK into `repo/`, `fdroid update -c`,
|
||||
re-upload. Keep the OLD apks in `repo/` too (users on older versions still update).
|
||||
|
||||
---
|
||||
|
||||
## B) Official F-Droid (fdroiddata)
|
||||
|
||||
Prereqs: the `SourceCode` repo in `fdroid/com.scarriffle.calendarr.yml` must be
|
||||
**publicly clonable** (make the Gitea repo public, or mirror to GitHub/GitLab and
|
||||
point `Repo:`/`SourceCode:` there), and a signed git tag `v1.1.0` must exist
|
||||
(created for this release).
|
||||
|
||||
1. Fork <https://gitlab.com/fdroid/fdroiddata>.
|
||||
2. Add our recipe as `metadata/com.scarriffle.calendarr.yml`
|
||||
(copy from `fdroid/com.scarriffle.calendarr.yml` in this repo).
|
||||
3. Optional local sanity check: `fdroid lint com.scarriffle.calendarr` and
|
||||
`fdroid build -v -l com.scarriffle.calendarr`.
|
||||
4. Open a Merge Request. F-Droid reviews, builds and publishes (this first review
|
||||
is the slow part — weeks). Afterwards, `AutoUpdateMode` picks up each new
|
||||
`v<versionName>` tag automatically.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots (both channels)
|
||||
Add PNG/JPG screenshots to
|
||||
`fastlane/metadata/android/en-US/images/phoneScreenshots/` (and `de-DE/…`),
|
||||
named `1.png`, `2.png`, … They then show on the F-Droid listing.
|
||||
674
LICENSE
Normal file
@@ -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>.
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application") version "8.10.0"
|
||||
id("org.jetbrains.kotlin.android") version "1.9.20"
|
||||
@@ -5,25 +8,47 @@ plugins {
|
||||
id("com.google.dagger.hilt.android") version "2.49"
|
||||
}
|
||||
|
||||
// Release signing is driven by a git-ignored keystore.properties in the repo
|
||||
// root (see keystore.properties.example). Absent (e.g. CI / fresh clone) the
|
||||
// release build simply stays unsigned — debug builds are unaffected.
|
||||
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||
val keystoreProps = Properties().apply {
|
||||
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.scarriffle.calendarr"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.scarriffle.calendarr"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
targetSdk = 35
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropsFile.exists()) {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProps.getProperty("storeFile"))
|
||||
storePassword = keystoreProps.getProperty("storePassword")
|
||||
keyAlias = keystoreProps.getProperty("keyAlias")
|
||||
keyPassword = keystoreProps.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
if (keystorePropsFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<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.READ_CONTACTS" />
|
||||
|
||||
<application
|
||||
android:name=".CalendarrApplication"
|
||||
@@ -21,5 +23,9 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".notifications.ReminderReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.scarriffle.calendarr
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.scarriffle.calendarr.ui.CalendarrRoot
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() {
|
||||
// Covers the window from the first frame (incl. warm-start), then hands
|
||||
// off to the in-app branded splash which stays until data is loaded.
|
||||
installSplashScreen()
|
||||
// Draw edge-to-edge so system-bar insets reach every window, including
|
||||
// ModalBottomSheets — otherwise navigationBarsPadding() inside the sheets
|
||||
// resolves to 0 and the bottom menu rows hide behind the nav bar.
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CalendarrRoot()
|
||||
|
||||
@@ -35,6 +35,13 @@ data class LoginResult(val token: String, val username: String, val isAdmin: Boo
|
||||
|
||||
data class TotpSetup(val secret: String, val qrUrl: String)
|
||||
|
||||
/** A single calendar's sync failure, surfaced alongside a (still-successful) events fetch.
|
||||
* [calendarId] is null for account-wide failures (no single calendar is at fault). */
|
||||
data class SyncError(val source: String, val name: String, val calendarId: Int?, val message: String)
|
||||
|
||||
/** Result of [CalendarRepository.fetchEvents]: the merged events plus any per-calendar sync failures. */
|
||||
data class EventsResult(val events: List<CalEvent>, val errors: List<SyncError>)
|
||||
|
||||
/**
|
||||
* Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi],
|
||||
* converts HTTP failures into [ApiException]s carrying the server's `detail`
|
||||
@@ -111,24 +118,31 @@ class CalendarRepository @Inject constructor(
|
||||
|
||||
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
|
||||
|
||||
suspend fun updateSettings(s: AppSettings) = guarded {
|
||||
api.updateSettings(
|
||||
jsonBody(
|
||||
"default_view" to s.defaultView,
|
||||
"week_start_day" to s.weekStartDay,
|
||||
"primary_color" to s.primaryColor,
|
||||
"accent_color" to s.accentColor,
|
||||
"today_color" to s.todayColor,
|
||||
"dim_past_events" to s.dimPastEvents,
|
||||
"text_contrast" to s.textContrast,
|
||||
"line_contrast" to s.lineContrast,
|
||||
"hour_height" to s.hourHeight,
|
||||
"language" to s.language,
|
||||
"month_divider_color" to s.monthDividerColor,
|
||||
"month_label_color" to s.monthLabelColor,
|
||||
"private_event_visibility" to s.privateEventVisibility,
|
||||
)
|
||||
).ensureSuccess()
|
||||
/** Push only the values whose sync flag is on (partial update; the server
|
||||
* leaves unsynced columns untouched), plus the account-wide flag map. */
|
||||
suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
|
||||
val body = mutableMapOf<String, Any?>()
|
||||
fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
|
||||
addIf("default_view", s.defaultView)
|
||||
addIf("week_start_day", s.weekStartDay)
|
||||
addIf("dim_past_events", s.dimPastEvents)
|
||||
addIf("hour_height", s.hourHeight)
|
||||
addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
|
||||
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
|
||||
addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
|
||||
addIf("primary_color", s.primaryColor)
|
||||
addIf("accent_color", s.accentColor)
|
||||
addIf("today_color", s.todayColor)
|
||||
addIf("text_color", s.textColor)
|
||||
addIf("bg_color", s.backgroundColor)
|
||||
addIf("line_color", s.lineColor)
|
||||
addIf("month_divider_color", s.monthDividerColor)
|
||||
addIf("month_label_color", s.monthLabelColor)
|
||||
addIf("cache_months", s.cacheMonths)
|
||||
addIf("month_view_paged", s.monthViewPaged)
|
||||
// The flag map is always account-wide; send it every push.
|
||||
body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>)
|
||||
api.updateSettings(jsonBody(body)).ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
|
||||
@@ -176,8 +190,17 @@ class CalendarRepository @Inject constructor(
|
||||
|
||||
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
|
||||
|
||||
suspend fun addLocalCalendar(name: String, color: String) =
|
||||
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) }
|
||||
suspend fun addLocalCalendar(
|
||||
name: String, color: String,
|
||||
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
|
||||
) = guarded {
|
||||
api.addLocalCalendar(jsonBody(buildMap {
|
||||
put("name", name)
|
||||
put("color", color)
|
||||
if (isBirthday) put("is_birthday", true)
|
||||
if (birthdayNotifyDaysBefore != null) put("birthday_notify_days_before", birthdayNotifyDaysBefore)
|
||||
}))
|
||||
}
|
||||
|
||||
suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() }
|
||||
|
||||
@@ -212,9 +235,11 @@ class CalendarRepository @Inject constructor(
|
||||
suspend fun deleteHomeAssistantAccount(id: Int) =
|
||||
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
|
||||
|
||||
/** Change a local calendar's colour. */
|
||||
/** Change a local calendar's colour. Uses the colour-only endpoint so share
|
||||
* recipients can recolour their view (own per-user colour) without needing
|
||||
* write access or being able to rename the calendar. */
|
||||
suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded {
|
||||
api.updateLocalCalendar(id, jsonBody("color" to color)).ensureSuccess()
|
||||
api.setLocalCalendarColor(id, jsonBody("color" to color)).ensureSuccess()
|
||||
}
|
||||
|
||||
/** Change an iCal subscription's colour. */
|
||||
@@ -244,10 +269,28 @@ class CalendarRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle a calendar's server-side `reminders_enabled` flag (all sources). */
|
||||
suspend fun setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Boolean) = guarded {
|
||||
val body = jsonBody("reminders_enabled" to enabled)
|
||||
when (source) {
|
||||
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
|
||||
"local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess()
|
||||
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
|
||||
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
|
||||
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve all calendars the user can create events in. */
|
||||
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
|
||||
val result = mutableListOf<WritableCalendar>()
|
||||
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()).forEach { cal ->
|
||||
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList())
|
||||
// Exclude read-only shared calendars — offering them in the event
|
||||
// editor only leads to a 403 on save. Own + read_write (incl. group)
|
||||
// calendars stay.
|
||||
.filter { it.owned || it.permission == "read_write" }
|
||||
.forEach { cal ->
|
||||
result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
|
||||
}
|
||||
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())
|
||||
@@ -281,35 +324,50 @@ class CalendarRepository @Inject constructor(
|
||||
|
||||
// ---- Events ----
|
||||
|
||||
suspend fun fetchEvents(start: Instant, end: Instant): List<CalEvent> = withContext(Dispatchers.IO) {
|
||||
suspend fun fetchEvents(start: Instant, end: Instant): EventsResult = withContext(Dispatchers.IO) {
|
||||
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
|
||||
resp.ensureSuccess()
|
||||
val raw = resp.body()?.string() ?: return@withContext emptyList()
|
||||
val raw = resp.body()?.string() ?: return@withContext EventsResult(emptyList(), emptyList())
|
||||
val root = JSONObject(raw)
|
||||
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val arr = root.optJSONArray("events")
|
||||
val events = buildList {
|
||||
if (arr != null) for (i in 0 until arr.length()) {
|
||||
val obj = arr.optJSONObject(i) ?: continue
|
||||
CalEvent.fromJson(obj)?.let { add(it) }
|
||||
}
|
||||
}
|
||||
val errArr = root.optJSONArray("errors")
|
||||
val errors = buildList {
|
||||
if (errArr != null) for (i in 0 until errArr.length()) {
|
||||
val obj = errArr.optJSONObject(i) ?: continue
|
||||
add(SyncError(
|
||||
source = obj.optString("source"),
|
||||
name = obj.optString("name"),
|
||||
calendarId = if (obj.has("calendar_id")) obj.optInt("calendar_id") else null,
|
||||
message = obj.optString("message"),
|
||||
))
|
||||
}
|
||||
}
|
||||
EventsResult(events, errors)
|
||||
}
|
||||
|
||||
suspend fun createLocalEvent(
|
||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = guarded {
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate))
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun updateLocalEvent(
|
||||
uid: String, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = guarded {
|
||||
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate))
|
||||
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
@@ -446,8 +504,13 @@ class CalendarRepository @Inject constructor(
|
||||
|
||||
// ---- Profile & targeted settings ----
|
||||
|
||||
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded {
|
||||
val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email))
|
||||
suspend fun updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Boolean? = null): String? = guarded {
|
||||
val resp = api.updateProfile(jsonBody(
|
||||
"display_name" to displayName,
|
||||
"username" to username,
|
||||
"email" to email,
|
||||
"directory_hidden" to directoryHidden,
|
||||
))
|
||||
resp.ensureSuccess()
|
||||
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
|
||||
}
|
||||
@@ -555,7 +618,8 @@ class CalendarRepository @Inject constructor(
|
||||
private fun eventBody(
|
||||
calendarId: Int?, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = jsonBody(
|
||||
buildMap {
|
||||
calendarId?.let { put("calendar_id", it) }
|
||||
@@ -567,6 +631,20 @@ class CalendarRepository @Inject constructor(
|
||||
put("description", description)
|
||||
if (!color.isNullOrBlank()) put("color", color)
|
||||
put("private", isPrivate)
|
||||
if (reminders != null) put("reminders", org.json.JSONArray(reminders))
|
||||
if (!rrule.isNullOrBlank()) put("rrule", rrule)
|
||||
if (birthYear != null) put("birth_year", birthYear)
|
||||
if (!externalUid.isNullOrBlank()) put("external_uid", externalUid)
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Birthdays (Contacts import) ----
|
||||
|
||||
suspend fun getBirthdayEntries(calendarId: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry> =
|
||||
guarded { api.getBirthdays(calendarId) }
|
||||
|
||||
suspend fun reportBirthdaySync(deviceId: String, deviceName: String, count: Int) = guarded {
|
||||
api.reportBirthdaySync(jsonBody("device_id" to deviceId, "device_name" to deviceName, "count" to count))
|
||||
.ensureSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.scarriffle.calendarr.data
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
|
||||
/** One birthday read from the address book. */
|
||||
data class ContactBirthday(
|
||||
val contactId: String,
|
||||
val name: String,
|
||||
val month: Int,
|
||||
val day: Int,
|
||||
val year: Int?,
|
||||
)
|
||||
|
||||
/** Reads birthdays from the system Contacts (requires READ_CONTACTS). */
|
||||
object ContactsReader {
|
||||
|
||||
fun readBirthdays(context: Context): List<ContactBirthday> {
|
||||
val out = mutableListOf<ContactBirthday>()
|
||||
val projection = arrayOf(
|
||||
ContactsContract.Data.CONTACT_ID,
|
||||
ContactsContract.CommonDataKinds.Event.START_DATE,
|
||||
ContactsContract.Data.DISPLAY_NAME,
|
||||
)
|
||||
val selection = "${ContactsContract.Data.MIMETYPE} = ? AND ${ContactsContract.CommonDataKinds.Event.TYPE} = ?"
|
||||
val args = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
|
||||
ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY.toString(),
|
||||
)
|
||||
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)
|
||||
?.use { c ->
|
||||
val idIdx = c.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
|
||||
val dateIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.START_DATE)
|
||||
val nameIdx = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME)
|
||||
while (c.moveToNext()) {
|
||||
val raw = c.getString(dateIdx) ?: continue
|
||||
val name = c.getString(nameIdx)?.takeIf { it.isNotBlank() } ?: continue
|
||||
val id = c.getString(idIdx) ?: continue
|
||||
val ymd = parseDate(raw) ?: continue
|
||||
out.add(ContactBirthday(id, name, ymd.second, ymd.third, ymd.first))
|
||||
}
|
||||
}
|
||||
// A contact could carry more than one birthday row — keep the first.
|
||||
return out.distinctBy { it.contactId }
|
||||
}
|
||||
|
||||
/** Returns (year?, month, day). Handles "yyyy-MM-dd", "--MM-dd" (no year),
|
||||
* and "yyyyMMdd". */
|
||||
private fun parseDate(raw: String): Triple<Int?, Int, Int>? = try {
|
||||
val s = raw.trim()
|
||||
when {
|
||||
s.startsWith("--") -> {
|
||||
val p = s.removePrefix("--").split("-")
|
||||
Triple(null, p[0].toInt(), p[1].toInt())
|
||||
}
|
||||
s.contains("-") -> {
|
||||
val p = s.split("-")
|
||||
if (p.size == 3) Triple(p[0].toIntOrNull()?.takeIf { it > 0 }, p[1].toInt(), p[2].toInt()) else null
|
||||
}
|
||||
s.length == 8 -> Triple(s.substring(0, 4).toInt().takeIf { it > 0 }, s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ class SettingsStore @Inject constructor(
|
||||
language = prefs.getString(K_LANGUAGE, null) ?: "de",
|
||||
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
|
||||
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
|
||||
textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF",
|
||||
backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000",
|
||||
lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52",
|
||||
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
|
||||
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60),
|
||||
cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3),
|
||||
monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false),
|
||||
)
|
||||
|
||||
fun saveSettings(s: AppSettings) {
|
||||
@@ -50,14 +57,127 @@ class SettingsStore @Inject constructor(
|
||||
.putString(K_LANGUAGE, s.language)
|
||||
.putString(K_DIVIDER, s.monthDividerColor)
|
||||
.putString(K_LABEL, s.monthLabelColor)
|
||||
.putString(K_TEXT_COLOR, s.textColor)
|
||||
.putString(K_BG_COLOR, s.backgroundColor)
|
||||
.putString(K_LINE_COLOR, s.lineColor)
|
||||
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
|
||||
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
|
||||
.putInt(K_CACHE_MONTHS, s.cacheMonths)
|
||||
.putBoolean(K_MONTH_PAGED, s.monthViewPaged)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// --- Per-setting cross-device sync flags (account-wide; server is authority) ---
|
||||
|
||||
/** Keys this Android client can sync. Excludes language (device "system" has
|
||||
* no server value) and text/line contrast (iOS-style opacity, device-local). */
|
||||
val syncableKeys: List<String> = listOf(
|
||||
"default_view", "week_start_day", "dim_past_events", "hour_height",
|
||||
"default_event_duration_minutes", "default_reminder_minutes",
|
||||
"primary_color", "accent_color", "today_color",
|
||||
"text_color", "bg_color", "line_color",
|
||||
"month_divider_color", "month_label_color",
|
||||
"cache_months", "month_view_paged",
|
||||
)
|
||||
|
||||
private val defaultSync: Map<String, Boolean> = syncableKeys.associateWith { key ->
|
||||
key != "cache_months" && key != "month_view_paged"
|
||||
}
|
||||
|
||||
fun loadSyncFlags(): Map<String, Boolean> {
|
||||
val result = defaultSync.toMutableMap()
|
||||
val raw = prefs.getString(K_SYNC_FLAGS, null)
|
||||
if (!raw.isNullOrBlank()) {
|
||||
runCatching { org.json.JSONObject(raw) }.getOrNull()?.let { obj ->
|
||||
for (key in syncableKeys) if (obj.has(key)) result[key] = obj.getBoolean(key)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun writeSyncFlags(f: Map<String, Boolean>) {
|
||||
prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply()
|
||||
}
|
||||
|
||||
fun storeServerFlags(server: Map<String, Boolean>?) {
|
||||
if (server == null) return
|
||||
val f = loadSyncFlags().toMutableMap()
|
||||
for (key in syncableKeys) server[key]?.let { f[key] = it }
|
||||
writeSyncFlags(f)
|
||||
}
|
||||
|
||||
fun setSyncFlag(key: String, on: Boolean) {
|
||||
val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f)
|
||||
}
|
||||
|
||||
fun setAllSyncFlags(on: Boolean) {
|
||||
val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f)
|
||||
}
|
||||
|
||||
/** Merge a server snapshot with local values honouring the (refreshed) flags:
|
||||
* synced keys take the server value, others keep the local value. Persists
|
||||
* the result and returns the effective settings. */
|
||||
fun applyServerPull(server: AppSettings): AppSettings {
|
||||
storeServerFlags(server.syncFlags)
|
||||
val flags = loadSyncFlags()
|
||||
val local = loadSettings()
|
||||
fun on(key: String) = flags[key] == true
|
||||
val eff = local.copy(
|
||||
defaultView = if (on("default_view")) server.defaultView else local.defaultView,
|
||||
weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay,
|
||||
dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents,
|
||||
hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight,
|
||||
defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes,
|
||||
defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes,
|
||||
primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor,
|
||||
accentColor = if (on("accent_color")) server.accentColor else local.accentColor,
|
||||
todayColor = if (on("today_color")) server.todayColor else local.todayColor,
|
||||
textColor = if (on("text_color")) server.textColor else local.textColor,
|
||||
backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor,
|
||||
lineColor = if (on("line_color")) server.lineColor else local.lineColor,
|
||||
monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor,
|
||||
monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor,
|
||||
cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths,
|
||||
monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged,
|
||||
// Device-local (never synced): language + contrast levels.
|
||||
language = local.language,
|
||||
textContrast = local.textContrast,
|
||||
lineContrast = local.lineContrast,
|
||||
)
|
||||
saveSettings(eff)
|
||||
return eff
|
||||
}
|
||||
|
||||
/** Device-local cache range in months around today (default 3). */
|
||||
var cacheMonths: Int
|
||||
get() = prefs.getInt(K_CACHE_MONTHS, 3)
|
||||
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply()
|
||||
|
||||
/** Device-local: month view as horizontal paged (swipe) instead of the
|
||||
* continuous vertical scroll feed (default false = scroll). */
|
||||
var monthViewPaged: Boolean
|
||||
get() = prefs.getBoolean(K_MONTH_PAGED, false)
|
||||
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
|
||||
|
||||
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
|
||||
var hideMenuButton: Boolean
|
||||
get() = prefs.getBoolean(K_HIDE_MENU, false)
|
||||
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
|
||||
|
||||
/** Whether this device mirrors its Contacts birthdays into the birthday calendar. */
|
||||
var birthdaysSyncEnabled: Boolean
|
||||
get() = prefs.getBoolean(K_BDAY_ENABLED, false)
|
||||
set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply()
|
||||
|
||||
/** Stable per-install id used to scope this device's contact-birthday rows. */
|
||||
val birthdaysDeviceId: String
|
||||
get() {
|
||||
prefs.getString(K_BDAY_DEVICE, null)?.let { return it }
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
prefs.edit().putString(K_BDAY_DEVICE, id).apply()
|
||||
return id
|
||||
}
|
||||
|
||||
// --- Hidden calendars ("source:id") ---
|
||||
|
||||
var hiddenCalendarKeys: Set<String>
|
||||
@@ -70,6 +190,27 @@ class SettingsStore @Inject constructor(
|
||||
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
|
||||
set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply()
|
||||
|
||||
// --- Reminder-disabled calendars ("source:id") ---
|
||||
// Mirrors the server's per-calendar `reminders_enabled` flag so the
|
||||
// notification scheduler can skip muted calendars without deleting any
|
||||
// event reminders.
|
||||
|
||||
var reminderDisabledCalendarKeys: Set<String>
|
||||
get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet()
|
||||
set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply()
|
||||
|
||||
// --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) ---
|
||||
|
||||
var calendarOrder: List<String>
|
||||
get() {
|
||||
val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val arr = org.json.JSONArray(raw)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply()
|
||||
|
||||
private companion object {
|
||||
const val K_DEFAULT_VIEW = "default_view"
|
||||
const val K_WEEK_START = "week_start_day"
|
||||
@@ -83,8 +224,20 @@ class SettingsStore @Inject constructor(
|
||||
const val K_LANGUAGE = "language"
|
||||
const val K_DIVIDER = "month_divider_color"
|
||||
const val K_LABEL = "month_label_color"
|
||||
const val K_TEXT_COLOR = "text_color"
|
||||
const val K_BG_COLOR = "bg_color"
|
||||
const val K_LINE_COLOR = "line_color"
|
||||
const val K_SYNC_FLAGS = "sync_flags"
|
||||
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
|
||||
const val K_DEFAULT_DURATION = "default_event_duration_minutes"
|
||||
const val K_CACHE_MONTHS = "cache_months"
|
||||
const val K_MONTH_PAGED = "month_view_paged"
|
||||
const val K_HIDE_MENU = "hide_menu_button"
|
||||
const val K_BDAY_ENABLED = "birthdays_sync_enabled"
|
||||
const val K_BDAY_DEVICE = "birthdays_device_id"
|
||||
const val K_HIDDEN = "hidden_calendar_keys"
|
||||
const val K_BANISHED = "banished_calendar_keys"
|
||||
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
|
||||
const val K_CAL_ORDER = "calendar_order"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,11 @@ interface CalendarrApi {
|
||||
@PUT("api/local/calendars/{id}")
|
||||
suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
// Colour-only update that works for owners AND share recipients (recipients
|
||||
// get their own per-user colour; owners change the calendar's colour).
|
||||
@PUT("api/local/calendars/{id}/color")
|
||||
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
@DELETE("api/local/calendars/{id}")
|
||||
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
|
||||
|
||||
@@ -217,6 +222,12 @@ interface CalendarrApi {
|
||||
@DELETE("api/local/events/{uid}")
|
||||
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
|
||||
|
||||
@GET("api/local/calendars/{id}/birthdays")
|
||||
suspend fun getBirthdays(@Path("id") id: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry>
|
||||
|
||||
@POST("api/birthdays/sync-report")
|
||||
suspend fun reportBirthdaySync(@Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
@POST("api/caldav/events")
|
||||
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ data class CalDAVCalendar(
|
||||
val color: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
||||
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
@@ -34,6 +35,9 @@ data class LocalCalendar(
|
||||
@Json(name = "shared_by") val sharedBy: String? = null,
|
||||
val permission: String? = null,
|
||||
val group: Boolean = false,
|
||||
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||
@Json(name = "is_birthday") val isBirthday: Boolean = false,
|
||||
@Json(name = "birthday_notify_days_before") val birthdayNotifyDaysBefore: Int? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
@@ -45,6 +49,7 @@ data class ICalSubscription(
|
||||
val enabled: Boolean = true,
|
||||
@Json(name = "refresh_minutes") val refreshMinutes: Int = 60,
|
||||
@Json(name = "last_fetched") val lastFetched: String? = null,
|
||||
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
@@ -61,6 +66,7 @@ data class GoogleCalendar(
|
||||
val color: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
||||
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
@@ -80,6 +86,7 @@ data class HACalendar(
|
||||
val color: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
||||
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
@@ -91,6 +98,19 @@ data class UserProfile(
|
||||
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
||||
@Json(name = "has_avatar") val hasAvatar: Boolean = false,
|
||||
@Json(name = "totp_enabled") val totpEnabled: Boolean = false,
|
||||
@Json(name = "directory_hidden") val directoryHidden: Boolean = false,
|
||||
)
|
||||
|
||||
/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced
|
||||
* rows carry an external_uid ("contact:<deviceId>:<contactId>"); manual ones null. */
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class BirthdayEntry(
|
||||
val uid: String,
|
||||
@Json(name = "external_uid") val externalUid: String? = null,
|
||||
val title: String = "",
|
||||
val month: Int? = null,
|
||||
val day: Int? = null,
|
||||
@Json(name = "birth_year") val birthYear: Int? = null,
|
||||
)
|
||||
|
||||
/** A calendar the user can create events in (resolved from all writable sources). */
|
||||
|
||||
@@ -22,9 +22,23 @@ data class AppSettings(
|
||||
@Json(name = "language") val language: String = "de",
|
||||
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
|
||||
@Json(name = "month_label_color") val monthLabelColor: String = "#7090c0",
|
||||
// Text / background / line colours drive the Material theme (onBackground,
|
||||
// background, outline) so the whole app follows them, matching web/iOS.
|
||||
@Json(name = "text_color") val textColor: String = "#FFFFFF",
|
||||
@Json(name = "bg_color") val backgroundColor: String = "#000000",
|
||||
@Json(name = "line_color") val lineColor: String = "#3A3A52",
|
||||
// How this user's private events appear to other group members: 'hidden' | 'busy'.
|
||||
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
|
||||
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
|
||||
// Minutes-before-start applied to all events client-side; null = off.
|
||||
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
|
||||
// Duration (minutes) applied to a newly created event's end time.
|
||||
@Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60,
|
||||
// Preload range in months (device-local by default) and month-view paging.
|
||||
@Json(name = "cache_months") val cacheMonths: Int = 3,
|
||||
@Json(name = "month_view_paged") val monthViewPaged: Boolean = false,
|
||||
// Account-wide per-setting sync flags, fully resolved by the server.
|
||||
@Json(name = "sync_flags") val syncFlags: Map<String, Boolean>? = null,
|
||||
) {
|
||||
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,23 @@ data class CalEvent(
|
||||
val owner: EventPerson? = null,
|
||||
val isGroupEvent: Boolean = false,
|
||||
val displayColor: String? = null,
|
||||
// Server-decorated title for the group combined view (group icon / owner
|
||||
// prefix); rendered in group mode while `title` stays raw for editing.
|
||||
val displayTitle: String? = null,
|
||||
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
||||
val reminders: List<Int> = emptyList(),
|
||||
// True for events from a calendar shared with the user read-only.
|
||||
val readOnly: Boolean = false,
|
||||
// True for events from a birthday calendar — clients show a cake icon and
|
||||
// the server bakes the age into `displayTitle`.
|
||||
val isBirthday: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Title to render: the server-decorated one (birthday age, group prefix)
|
||||
* wins over the raw title, which is kept for editing.
|
||||
*/
|
||||
val renderTitle: String
|
||||
get() = displayTitle?.takeIf { it.isNotBlank() } ?: title
|
||||
/**
|
||||
* Group view supplies a server-resolved colour (display_color); otherwise
|
||||
* per-event override colour, then the calendar's colour, then a stable
|
||||
@@ -117,6 +133,12 @@ data class CalEvent(
|
||||
owner = personFrom(json, "owner"),
|
||||
isGroupEvent = json.optBoolean("is_group_event", false),
|
||||
displayColor = json.strOrNull("display_color"),
|
||||
displayTitle = json.strOrNull("display_title"),
|
||||
reminders = json.optJSONArray("reminders")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
|
||||
} ?: emptyList(),
|
||||
readOnly = json.optBoolean("read_only", false),
|
||||
isBirthday = json.optBoolean("is_birthday", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.scarriffle.calendarr.domain.model
|
||||
|
||||
/**
|
||||
* Reminder offsets are stored as minutes-before-start integers (0 = at start).
|
||||
* A few quick presets are offered; anything else is entered as a custom
|
||||
* number + unit. Mirrors the iOS `ReminderOptions`.
|
||||
*/
|
||||
object ReminderOptions {
|
||||
/** Quick presets: at start, 30 min, 1 day. */
|
||||
val presets = listOf(0, 30, 1440)
|
||||
|
||||
/** Default for a freshly-switched custom row (deliberately not a preset). */
|
||||
const val customDefault = 120
|
||||
|
||||
enum class Unit(val mult: Int, val labelKey: String) {
|
||||
MINUTES(1, "event.reminder_unit.minutes"),
|
||||
HOURS(60, "event.reminder_unit.hours"),
|
||||
DAYS(1440, "event.reminder_unit.days"),
|
||||
WEEKS(10080, "event.reminder_unit.weeks"),
|
||||
}
|
||||
|
||||
/** Split a minutes value into the largest exact {value, unit} for the custom picker. */
|
||||
fun split(minutes: Int): Pair<Int, Unit> {
|
||||
for (u in Unit.values().reversed()) {
|
||||
if (minutes > 0 && minutes % u.mult == 0) return (minutes / u.mult) to u
|
||||
}
|
||||
return maxOf(1, minutes) to Unit.MINUTES
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.scarriffle.calendarr.notifications
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.ui.calendar.calendarKey
|
||||
|
||||
/**
|
||||
* Schedules OS reminder notifications for upcoming events via AlarmManager.
|
||||
* Per-event reminders take precedence; otherwise the user's default reminder
|
||||
* applies. Calendars the user muted (`disabledKeys`) are skipped — their
|
||||
* reminders are kept on the events, just never fired. Mirrors the iOS
|
||||
* `NotificationScheduler`.
|
||||
*/
|
||||
object NotificationScheduler {
|
||||
const val CHANNEL_ID = "calendarr_reminders"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_BODY = "body"
|
||||
const val EXTRA_ID = "id"
|
||||
|
||||
private const val PREFS = "calendarr_reminders_sched"
|
||||
private const val KEY_COUNT = "scheduled_count"
|
||||
private const val MAX = 50 // keep the alarm count bounded
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val mgr = context.getSystemService(NotificationManager::class.java)
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Reminders", NotificationManager.IMPORTANCE_HIGH)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Pending(val fire: Long, val title: String, val body: String)
|
||||
|
||||
fun reschedule(
|
||||
context: Context,
|
||||
events: List<CalEvent>,
|
||||
disabledKeys: Set<String>,
|
||||
defaultMinutes: Int,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val pending = mutableListOf<Pending>()
|
||||
for (ev in events) {
|
||||
if (disabledKeys.contains(calendarKey(ev.source, ev.calendarId))) continue
|
||||
val offsets = if (ev.reminders.isEmpty()) {
|
||||
if (defaultMinutes >= 0) listOf(defaultMinutes) else emptyList()
|
||||
} else ev.reminders
|
||||
for (m in offsets) {
|
||||
val fire = ev.startDate.toEpochMilli() - m * 60_000L
|
||||
if (fire > now) {
|
||||
val rel = relativeText(m)
|
||||
val body = if (ev.location.isNotBlank()) "$rel · ${ev.location}" else rel
|
||||
pending.add(Pending(fire, ev.title, body))
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.sortBy { it.fire }
|
||||
val limited = pending.take(MAX)
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val lastCount = prefs.getInt(KEY_COUNT, 0)
|
||||
// Cancel every alarm from the previous run (extras are ignored when
|
||||
// matching a PendingIntent, so a bare intent with the same code cancels).
|
||||
for (i in 0 until maxOf(lastCount, limited.size)) {
|
||||
am.cancel(intentFor(context, i, null))
|
||||
}
|
||||
limited.forEachIndexed { i, p ->
|
||||
val pi = intentFor(context, i, p)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, p.fire, pi)
|
||||
} else {
|
||||
am.set(AlarmManager.RTC_WAKEUP, p.fire, pi)
|
||||
}
|
||||
}
|
||||
prefs.edit().putInt(KEY_COUNT, limited.size).apply()
|
||||
}
|
||||
|
||||
private fun relativeText(minutes: Int): String = when {
|
||||
minutes < 60 -> "in $minutes Min."
|
||||
minutes == 60 -> "in 1 Std."
|
||||
minutes < 1440 -> "in ${minutes / 60} Std."
|
||||
minutes == 1440 -> "morgen"
|
||||
else -> "in ${minutes / 1440} Tagen"
|
||||
}
|
||||
|
||||
private fun intentFor(context: Context, code: Int, p: Pending?): PendingIntent {
|
||||
val intent = Intent(context, ReminderReceiver::class.java).apply {
|
||||
// Distinct action per code so PendingIntents don't collapse together.
|
||||
action = "com.scarriffle.calendarr.REMINDER_$code"
|
||||
if (p != null) {
|
||||
putExtra(EXTRA_ID, code)
|
||||
putExtra(EXTRA_TITLE, p.title)
|
||||
putExtra(EXTRA_BODY, p.body)
|
||||
}
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||
return PendingIntent.getBroadcast(context, code, intent, flags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.scarriffle.calendarr.notifications
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.scarriffle.calendarr.R
|
||||
|
||||
/** Posts the reminder notification when an AlarmManager alarm fires. */
|
||||
class ReminderReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
NotificationScheduler.ensureChannel(context)
|
||||
val title = intent.getStringExtra(NotificationScheduler.EXTRA_TITLE) ?: return
|
||||
val body = intent.getStringExtra(NotificationScheduler.EXTRA_BODY) ?: ""
|
||||
val id = intent.getIntExtra(NotificationScheduler.EXTRA_ID, 0)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationScheduler.CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
// notify() is a no-op (and may throw on some OEMs) without the runtime
|
||||
// POST_NOTIFICATIONS permission; ignore that case.
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(id, notification)
|
||||
} catch (_: SecurityException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.scarriffle.calendarr.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -48,19 +50,29 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
|
||||
}
|
||||
|
||||
when (route) {
|
||||
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
|
||||
AppRoute.LOGIN -> LoginScreen(
|
||||
// Auth screens draw full-screen; inset them from the system
|
||||
// bars (edge-to-edge) so their content isn't under status/nav.
|
||||
AppRoute.SETUP -> Box(Modifier.systemBarsPadding()) {
|
||||
ServerSetupScreen(onConfigured = vm::onServerConfigured)
|
||||
}
|
||||
AppRoute.LOGIN -> Box(Modifier.systemBarsPadding()) {
|
||||
LoginScreen(
|
||||
serverUrl = vm.serverUrl,
|
||||
onLoggedIn = vm::onLoggedIn,
|
||||
onBack = vm::switchServer,
|
||||
)
|
||||
AppRoute.MAIN -> CalendarScreen(
|
||||
vm = calendarVm!!,
|
||||
}
|
||||
AppRoute.MAIN -> calendarVm?.let { cvm ->
|
||||
CalendarScreen(
|
||||
vm = cvm,
|
||||
onLogout = vm::logout,
|
||||
onSwitchServer = vm::switchServer,
|
||||
onSettingsChanged = vm::applyLocalSettings,
|
||||
onSettingsSynced = vm::refreshSettings,
|
||||
username = vm.username,
|
||||
serverUrl = vm.serverUrl,
|
||||
)
|
||||
} ?: SplashScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ object L10n {
|
||||
"cal.no_events_title" to "Keine Termine",
|
||||
"cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.",
|
||||
"cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis",
|
||||
"cal.show_in_day_view" to "In Tagesansicht öffnen",
|
||||
"cal.show_in_week_view" to "In Wochenansicht öffnen",
|
||||
"cal.no_events_day" to "Keine Termine an diesem Tag",
|
||||
"menu.section.settings" to "Einstellungen", "menu.profile" to "Profil",
|
||||
"menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender",
|
||||
"menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin",
|
||||
@@ -52,6 +55,25 @@ object L10n {
|
||||
"settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
|
||||
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
|
||||
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
|
||||
"settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe",
|
||||
"settings.color.line" to "Linienfarbe",
|
||||
"settings.sync_all" to "Alle synchronisieren",
|
||||
"settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen",
|
||||
"settings.sync_this" to "Zwischen Geräten synchronisieren",
|
||||
"settings.reset" to "Zurücksetzen",
|
||||
"settings.appearance" to "Ansicht",
|
||||
"settings.device" to "Nur auf diesem Gerät",
|
||||
"settings.more" to "Mehr",
|
||||
"settings.hide_menu_button" to "Menü-Button ausblenden",
|
||||
"settings.month_mode" to "Monatswechsel",
|
||||
"settings.month_mode.scroll" to "Fortlaufend scrollen",
|
||||
"settings.month_mode.paged" to "Seitenweise blättern",
|
||||
"settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
|
||||
"settings.directory_hidden" to "Profil verbergen",
|
||||
"settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.",
|
||||
"settings.defaultreminder" to "Standard-Erinnerung",
|
||||
"reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit",
|
||||
"reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher",
|
||||
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
|
||||
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
|
||||
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
|
||||
@@ -60,6 +82,7 @@ object L10n {
|
||||
"settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
|
||||
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
|
||||
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
|
||||
"settings.month_paged" to "Monatsansicht seitenweise wischen",
|
||||
"settings.hourheight" to "Stundenhöhe",
|
||||
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
|
||||
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross",
|
||||
@@ -98,6 +121,14 @@ object L10n {
|
||||
"event.detail_title" to "Termin", "event.source" to "Quelle",
|
||||
"event.save" to "Sichern", "event.add" to "Hinzufügen",
|
||||
"event.delete_confirm" to "Diesen Termin löschen?",
|
||||
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
|
||||
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
|
||||
"event.reminder_before" to "vorher",
|
||||
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
|
||||
"event.reminder_unit.days" to "Tage", "event.reminder_unit.weeks" to "Wochen",
|
||||
"event.reminders_disabled" to "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.",
|
||||
"settings.default_duration" to "Standard-Termindauer",
|
||||
"filter.reminders_on" to "Benachrichtigungen aktivieren", "filter.reminders_off" to "Benachrichtigungen deaktivieren",
|
||||
"accounts.title" to "Konten", "accounts.loading" to "Lade Konten…",
|
||||
"accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-Konten",
|
||||
"accounts.caldav.add" to "CalDAV hinzufügen", "accounts.local.header" to "Lokale Kalender",
|
||||
@@ -110,12 +141,35 @@ object L10n {
|
||||
"accounts.ha.add" to "Home Assistant hinzufügen",
|
||||
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
|
||||
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
|
||||
"filter.sort" to "Sortieren", "filter.done" to "Fertig",
|
||||
"filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten",
|
||||
"filter.button" to "Kalender ein-/ausblenden",
|
||||
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
|
||||
"filter.banish" to "Dauerhaft ausblenden",
|
||||
"filter.read_only" to "Nur lesen",
|
||||
"filter.banished_footer" to "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
|
||||
"accounts.banished_header" to "Ausgeblendete Kalender",
|
||||
"accounts.banished_unhide" to "Wieder einblenden",
|
||||
"accounts.banished_unknown" to "Unbekannter Kalender",
|
||||
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
|
||||
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
|
||||
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
|
||||
"local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
|
||||
"local.create" to "Erstellen",
|
||||
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
|
||||
"birthday.calendar_name" to "Geburtstage",
|
||||
"birthday.activate" to "Geburtstagskalender aktivieren",
|
||||
"birthday.import_contacts" to "Aus Kontakten importieren",
|
||||
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
|
||||
"birthday.is_calendar" to "Geburtstagskalender",
|
||||
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
|
||||
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
|
||||
"birthday.target" to "Geburtstagskalender",
|
||||
"birthday.no_calendars" to "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender\".",
|
||||
"birthday.notify" to "Erinnerung", "birthday.notify.off" to "Aus",
|
||||
"birthday.notify.same_day" to "Am Tag", "birthday.notify.one_day" to "1 Tag vorher",
|
||||
"birthday.notify.days" to "%d Tage vorher",
|
||||
"birthday.created" to "Geburtstag „%s\" hinzugefügt",
|
||||
"ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL",
|
||||
"ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren",
|
||||
"ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.",
|
||||
@@ -165,6 +219,9 @@ object L10n {
|
||||
"cal.no_events_title" to "No events",
|
||||
"cal.no_events_body" to "No events in the next 90 days.",
|
||||
"cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event",
|
||||
"cal.show_in_day_view" to "Open in day view",
|
||||
"cal.show_in_week_view" to "Open in week view",
|
||||
"cal.no_events_day" to "No events on this day",
|
||||
"menu.section.settings" to "Settings", "menu.profile" to "Profile",
|
||||
"menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars",
|
||||
"menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin",
|
||||
@@ -179,6 +236,25 @@ object L10n {
|
||||
"settings.colors" to "Colors", "settings.color.primary" to "Primary color",
|
||||
"settings.color.accent" to "Accent color", "settings.color.today" to "Today color",
|
||||
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
|
||||
"settings.color.text" to "Text color", "settings.color.background" to "Background color",
|
||||
"settings.color.line" to "Line color",
|
||||
"settings.sync_all" to "Sync all",
|
||||
"settings.sync_all.desc" to "Share these settings across your devices",
|
||||
"settings.sync_this" to "Sync across devices",
|
||||
"settings.reset" to "Reset",
|
||||
"settings.appearance" to "View",
|
||||
"settings.device" to "This device only",
|
||||
"settings.more" to "More",
|
||||
"settings.hide_menu_button" to "Hide menu button",
|
||||
"settings.month_mode" to "Month switching",
|
||||
"settings.month_mode.scroll" to "Continuous scroll",
|
||||
"settings.month_mode.paged" to "Page by page",
|
||||
"settings.device.footer" to "These settings apply to this device only and are not synced.",
|
||||
"settings.directory_hidden" to "Hide profile",
|
||||
"settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.",
|
||||
"settings.defaultreminder" to "Default reminder",
|
||||
"reminder.off" to "Off", "reminder.at_start" to "At start time",
|
||||
"reminder.1d" to "1 day before", "reminder.1w" to "1 week before",
|
||||
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
|
||||
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
|
||||
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
|
||||
@@ -187,6 +263,7 @@ object L10n {
|
||||
"settings.calview" to "Calendar view", "settings.defaultview" to "Default view",
|
||||
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
|
||||
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events",
|
||||
"settings.month_paged" to "Swipe month view as pages",
|
||||
"settings.hourheight" to "Hour height",
|
||||
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
|
||||
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",
|
||||
@@ -225,6 +302,14 @@ object L10n {
|
||||
"event.detail_title" to "Event", "event.source" to "Source",
|
||||
"event.save" to "Save", "event.add" to "Add",
|
||||
"event.delete_confirm" to "Delete this event?",
|
||||
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
|
||||
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
|
||||
"event.reminder_before" to "before",
|
||||
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
|
||||
"event.reminder_unit.days" to "days", "event.reminder_unit.weeks" to "weeks",
|
||||
"event.reminders_disabled" to "Reminders are disabled for this calendar – they will not fire.",
|
||||
"settings.default_duration" to "Default event duration",
|
||||
"filter.reminders_on" to "Enable reminders", "filter.reminders_off" to "Disable reminders",
|
||||
"accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…",
|
||||
"accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV accounts",
|
||||
"accounts.caldav.add" to "Add CalDAV", "accounts.local.header" to "Local calendars",
|
||||
@@ -237,12 +322,35 @@ object L10n {
|
||||
"accounts.ha.add" to "Add Home Assistant",
|
||||
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
|
||||
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
|
||||
"filter.sort" to "Sort", "filter.done" to "Done",
|
||||
"filter.move_up" to "Move up", "filter.move_down" to "Move down",
|
||||
"filter.button" to "Show/hide calendars",
|
||||
"filter.sync_error" to "Sync failed",
|
||||
"filter.banish" to "Hide permanently",
|
||||
"filter.read_only" to "Read-only",
|
||||
"filter.banished_footer" to "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
|
||||
"accounts.banished_header" to "Hidden calendars",
|
||||
"accounts.banished_unhide" to "Show again",
|
||||
"accounts.banished_unknown" to "Unknown calendar",
|
||||
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
|
||||
"caldav.username" to "Username", "caldav.password" to "Password",
|
||||
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",
|
||||
"local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
|
||||
"local.create" to "Create",
|
||||
"birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday",
|
||||
"birthday.calendar_name" to "Birthdays",
|
||||
"birthday.activate" to "Enable birthday calendar",
|
||||
"birthday.import_contacts" to "Import from contacts",
|
||||
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
|
||||
"birthday.is_calendar" to "Birthday calendar",
|
||||
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
|
||||
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
|
||||
"birthday.target" to "Birthday calendar",
|
||||
"birthday.no_calendars" to "No birthday calendar yet. Create a calendar and enable \"Birthday calendar\" first.",
|
||||
"birthday.notify" to "Reminder", "birthday.notify.off" to "Off",
|
||||
"birthday.notify.same_day" to "On the day", "birthday.notify.one_day" to "1 day before",
|
||||
"birthday.notify.days" to "%d days before",
|
||||
"birthday.created" to "Birthday \"%s\" added",
|
||||
"ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL",
|
||||
"ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe",
|
||||
"ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min",
|
||||
|
||||
@@ -62,12 +62,12 @@ class MainViewModel @Inject constructor(
|
||||
_route.value = computeRoute()
|
||||
}
|
||||
|
||||
/** Pull appearance settings from the server, caching them locally. */
|
||||
/** Pull settings from the server and merge them with local values honouring
|
||||
* each setting's sync flag (synced keys take the server value). */
|
||||
fun refreshSettings() {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.getSettings() }.onSuccess { s ->
|
||||
settingsStore.saveSettings(s)
|
||||
_settings.value = s
|
||||
_settings.value = settingsStore.applyServerPull(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -116,6 +117,26 @@ fun AccountsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Calendars permanently hidden ("banished") on the server — offered for
|
||||
// re-enabling. Derived from the loaded account lists' sidebar_hidden flags.
|
||||
val banishedCals: List<BanishedCalendar> = buildList {
|
||||
vm.caldav.forEach { acc ->
|
||||
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
|
||||
add(BanishedCalendar("caldav", it.id, "${acc.name} – ${it.name}", it.color ?: acc.color))
|
||||
}
|
||||
}
|
||||
vm.google.forEach { acc ->
|
||||
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
|
||||
add(BanishedCalendar("google", it.id, "${acc.email} – ${it.name}", it.color ?: "#4285f4"))
|
||||
}
|
||||
}
|
||||
vm.homeAssistant.forEach { acc ->
|
||||
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
|
||||
add(BanishedCalendar("homeassistant", it.id, "${acc.name} – ${it.name}", it.color ?: "#46bdc6"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -197,6 +218,14 @@ fun AccountsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Banished (permanently hidden) calendars — re-enable here.
|
||||
if (banishedCals.isNotEmpty()) {
|
||||
item { SectionHeaderNoAdd(tr("accounts.banished_header")) }
|
||||
items(banishedCals, key = { "b${it.source}${it.id}" }) { cal ->
|
||||
BanishedCalendarRow(cal.label, cal.color) { vm.unbanishCalendar(cal.source, cal.id, onChanged) }
|
||||
}
|
||||
}
|
||||
|
||||
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
|
||||
item { Spacer(Modifier.size(40.dp)) }
|
||||
}
|
||||
@@ -308,6 +337,26 @@ private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A permanently-hidden ("banished") calendar with a "show again" button. */
|
||||
private data class BanishedCalendar(val source: String, val id: Int, val label: String, val color: String)
|
||||
|
||||
@Composable
|
||||
private fun BanishedCalendarRow(name: String, color: String, onUnbanish: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ColorDot(color, editable = false, onClick = {})
|
||||
Text(
|
||||
name,
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onUnbanish) { Text(tr("accounts.banished_unhide")) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A simple row (iCal) with editable colour + delete. */
|
||||
@Composable
|
||||
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {
|
||||
@@ -332,7 +381,9 @@ private fun LocalCalendarRow(
|
||||
) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
ColorDot(cal.color, editable = cal.owned, onClick = onColor)
|
||||
// Recipients of a shared calendar may recolour it (their own per-user
|
||||
// colour); only renaming/other management stays owner-only.
|
||||
ColorDot(cal.color, editable = true, onClick = onColor)
|
||||
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(cal.name, style = MaterialTheme.typography.bodyLarge)
|
||||
@@ -380,7 +431,7 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()),
|
||||
Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
@@ -61,8 +61,12 @@ class AccountsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun addLocal(name: String, color: String, onChanged: () -> Unit) =
|
||||
mutate(onChanged) { repository.addLocalCalendar(name, color) }
|
||||
fun addLocal(
|
||||
name: String, color: String, onChanged: () -> Unit,
|
||||
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
|
||||
) = mutate(onChanged) {
|
||||
repository.addLocalCalendar(name, color, isBirthday, birthdayNotifyDaysBefore)
|
||||
}
|
||||
|
||||
fun deleteLocal(id: Int, onChanged: () -> Unit) =
|
||||
mutate(onChanged) { repository.deleteLocalCalendar(id) }
|
||||
@@ -99,6 +103,13 @@ class AccountsViewModel @Inject constructor(
|
||||
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
|
||||
mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) }
|
||||
|
||||
// ---- Banished (permanently hidden) calendars ----
|
||||
|
||||
/** Lift the server-side sidebar_hidden flag so a banished calendar reappears.
|
||||
* `onChanged` triggers the calendar screen's reconcile + refetch. */
|
||||
fun unbanishCalendar(source: String, calendarId: Int, onChanged: () -> Unit) =
|
||||
mutate(onChanged) { repository.setCalendarSidebarHidden(source, calendarId, hidden = false) }
|
||||
|
||||
// ---- Sharing ----
|
||||
|
||||
var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList())
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
@@ -102,7 +103,7 @@ fun LoginScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (vm.loggingIn) {
|
||||
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(tr("auth.login"))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
@@ -78,7 +79,7 @@ fun ServerSetupScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (vm.checking) {
|
||||
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(tr("auth.continue"))
|
||||
}
|
||||
|
||||
@@ -98,7 +98,12 @@ private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: (
|
||||
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||
EventLabel(
|
||||
event = event,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (event.location.isNotBlank()) {
|
||||
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.domain.model.LocalCalendar
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Minimal "new birthday" mask for the user's single birthday calendar: name +
|
||||
* date (with an optional "year unknown"). Saves an all-day, yearly-recurring
|
||||
* local event; the server adds the age suffix and cake icon. If no birthday
|
||||
* calendar exists yet, offers to activate one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BirthdayDialog(
|
||||
calendar: LocalCalendar?,
|
||||
onDismiss: () -> Unit,
|
||||
onActivate: () -> Unit,
|
||||
onImportContacts: () -> Unit,
|
||||
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var yearUnknown by remember { mutableStateOf(false) }
|
||||
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(tr("birthday.new_title")) },
|
||||
text = {
|
||||
Column {
|
||||
if (calendar == null) {
|
||||
Text(tr("birthday.activate_hint"), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
name, { name = it },
|
||||
label = { Text(tr("birthday.person")) },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
FilterChip(
|
||||
selected = true, onClick = { showPicker = true },
|
||||
label = { Text("${tr("birthday.date")}: ${formatBirthday(pickedDate, yearUnknown)}") },
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
FilterChip(
|
||||
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
|
||||
label = { Text(tr("birthday.year_unknown")) },
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (calendar == null) {
|
||||
TextButton(onClick = onActivate) { Text(tr("birthday.activate")) }
|
||||
} else {
|
||||
TextButton(
|
||||
enabled = name.isNotBlank(),
|
||||
onClick = { onSave(name.trim(), pickedDate, !yearUnknown) },
|
||||
) { Text(tr("common.save")) }
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
|
||||
)
|
||||
|
||||
if (showPicker) {
|
||||
val dpState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = pickedDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showPicker = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
dpState.selectedDateMillis?.let { millis ->
|
||||
pickedDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
}
|
||||
showPicker = false
|
||||
}) { Text(tr("common.save")) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showPicker = false }) { Text(tr("common.cancel")) } },
|
||||
) { DatePicker(state = dpState) }
|
||||
}
|
||||
}
|
||||
|
||||
private val DAY_MONTH = DateTimeFormatter.ofPattern("dd.MM.")
|
||||
private val DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
private fun formatBirthday(date: LocalDate, yearUnknown: Boolean): String =
|
||||
date.format(if (yearUnknown) DAY_MONTH else DAY_MONTH_YEAR)
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||
import com.scarriffle.calendarr.domain.model.Group
|
||||
import com.scarriffle.calendarr.ui.groups.GroupIcon
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
|
||||
/**
|
||||
* The left navigation drawer: calendar visibility (flat, reorderable) + view /
|
||||
* group switching + a header with manual sync, the full menu, and close. Mirrors
|
||||
* the iOS side drawer. Order is device-local (CalendarViewModel.calendarOrder).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CalendarDrawerContent(
|
||||
state: CalendarUiState,
|
||||
vm: CalendarViewModel,
|
||||
username: String,
|
||||
serverUrl: String,
|
||||
calendars: List<CalendarFilterEntry>,
|
||||
onOpenMenu: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
var isSorting by remember { mutableStateOf(false) }
|
||||
|
||||
// Working order: entries sorted by the stored order (unknown → end, by name).
|
||||
var order by remember(calendars, vm.calendarOrder) {
|
||||
mutableStateOf(
|
||||
calendars.sortedWith(
|
||||
compareBy(
|
||||
{ vm.calendarOrder.indexOf(it.key).let { i -> if (i < 0) Int.MAX_VALUE else i } },
|
||||
{ it.name.lowercase() },
|
||||
)
|
||||
).map { it.key }
|
||||
)
|
||||
}
|
||||
val entries = order.mapNotNull { key -> calendars.firstOrNull { it.key == key } }
|
||||
|
||||
fun move(index: Int, delta: Int) {
|
||||
val to = index + delta
|
||||
if (to < 0 || to >= order.size) return
|
||||
val m = order.toMutableList()
|
||||
val x = m.removeAt(index); m.add(to, x)
|
||||
order = m
|
||||
vm.setCalendarOrder(m)
|
||||
}
|
||||
|
||||
ModalDrawerSheet(Modifier.width(320.dp).fillMaxHeight()) {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
header(username, serverUrl, onSync, onOpenMenu, onClose)
|
||||
Divider()
|
||||
viewSwitcher(state.viewType) { vm.setViewType(it) }
|
||||
Divider()
|
||||
if (state.groups.isNotEmpty()) {
|
||||
groupSwitcher(state.groups, state.activeGroup) { vm.switchGroup(it) }
|
||||
Divider()
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(tr("filter.title"), style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = { isSorting = !isSorting }) {
|
||||
Text(tr(if (isSorting) "filter.done" else "filter.sort"))
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxWidth().weight(1f).navigationBarsPadding()) {
|
||||
items(entries, key = { it.key }) { entry ->
|
||||
CalendarRow(
|
||||
entry = entry,
|
||||
visible = entry.key !in state.hiddenKeys,
|
||||
reminderDisabled = entry.key in state.reminderDisabledKeys,
|
||||
sorting = isSorting,
|
||||
onToggle = { vm.setCalendarHidden(entry.key, !it) },
|
||||
onBanish = { vm.setCalendarBanished(entry.key, banished = true) },
|
||||
onToggleReminders = { vm.setCalendarRemindersDisabled(entry.key, disabled = it) },
|
||||
onMoveUp = { move(order.indexOf(entry.key), -1) },
|
||||
onMoveDown = { move(order.indexOf(entry.key), 1) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun header(
|
||||
username: String,
|
||||
serverUrl: String,
|
||||
onSync: () -> Unit,
|
||||
onOpenMenu: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().statusBarsPadding().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(38.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(username.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text(username, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
IconButton(onClick = onSync, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = tr("menu.sync"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onClick = onOpenMenu, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = tr("menu.appearance"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Filled.Close, contentDescription = tr("common.close"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun viewSwitcher(current: CalViewType, onSelect: (CalViewType) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CalViewType.entries.forEach { type ->
|
||||
FilterChip(
|
||||
selected = current == type,
|
||||
onClick = { onSelect(type) },
|
||||
label = { Text(tr("view.${type.key}")) },
|
||||
leadingIcon = { Icon(type.icon, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun groupSwitcher(groups: List<Group>, active: Group?, onSwitch: (Group?) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = active == null,
|
||||
onClick = { onSwitch(null) },
|
||||
label = { Text(tr("group.switch.personal")) },
|
||||
)
|
||||
groups.forEach { g ->
|
||||
FilterChip(
|
||||
selected = active?.id == g.id,
|
||||
onClick = { onSwitch(g) },
|
||||
label = { Text(g.name) },
|
||||
leadingIcon = { GroupIcon(g.icon) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CalendarRow(
|
||||
entry: CalendarFilterEntry,
|
||||
visible: Boolean,
|
||||
reminderDisabled: Boolean,
|
||||
sorting: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onBanish: () -> Unit,
|
||||
onToggleReminders: (Boolean) -> Unit,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { if (!sorting) onToggle(visible) },
|
||||
onLongClick = { if (!sorting) menuOpen = true },
|
||||
)
|
||||
.padding(start = 16.dp, end = 4.dp, top = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(entry.color)))
|
||||
Text(
|
||||
entry.name,
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
color = if (visible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (entry.readOnly) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = tr("filter.read_only"),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp).size(14.dp))
|
||||
}
|
||||
if (sorting) {
|
||||
IconButton(onClick = onMoveUp, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = tr("filter.move_up"))
|
||||
}
|
||||
IconButton(onClick = onMoveDown, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = tr("filter.move_down"))
|
||||
}
|
||||
} else {
|
||||
if (reminderDisabled) {
|
||||
Icon(Icons.Filled.NotificationsOff, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 2.dp).size(15.dp))
|
||||
}
|
||||
IconButton(onClick = { onToggle(visible) }, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
if (visible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (visible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(tr(if (reminderDisabled) "filter.reminders_on" else "filter.reminders_off")) },
|
||||
leadingIcon = { Icon(if (reminderDisabled) Icons.Filled.Notifications else Icons.Filled.NotificationsOff, contentDescription = null) },
|
||||
onClick = { menuOpen = false; onToggleReminders(!reminderDisabled) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(tr("filter.banish")) },
|
||||
leadingIcon = { Icon(Icons.Filled.Archive, contentDescription = null) },
|
||||
onClick = { menuOpen = false; onBanish() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,21 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -26,7 +37,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
|
||||
data class CalendarFilterEntry(val key: String, val name: String, val color: String)
|
||||
data class CalendarFilterEntry(val key: String, val name: String, val color: String, val source: String = "", val readOnly: Boolean = false)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -36,9 +47,28 @@ fun CalendarFilterSheet(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
val groupMode = state.activeGroup != null
|
||||
|
||||
// In group mode the filter lists members (+ the group calendar) so they can
|
||||
// be hidden individually, Outlook-style; otherwise the normal calendars.
|
||||
val groupEntries: List<CalendarFilterEntry> = if (groupMode) {
|
||||
buildList {
|
||||
state.activeGroupMembers.forEach { m ->
|
||||
add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4"))
|
||||
}
|
||||
add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4"))
|
||||
}
|
||||
} else emptyList()
|
||||
val rows = if (groupMode) groupEntries else events
|
||||
val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -46,36 +76,91 @@ fun CalendarFilterSheet(
|
||||
) {
|
||||
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Row {
|
||||
TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) }
|
||||
TextButton(onClick = {
|
||||
vm.setHiddenCalendars(events.map { it.key }.toSet())
|
||||
if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet())
|
||||
}) { Text(tr("filter.show_all")) }
|
||||
TextButton(onClick = {
|
||||
if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet())
|
||||
else vm.setHiddenCalendars(rows.map { it.key }.toSet())
|
||||
}) { Text(tr("filter.hide_all")) }
|
||||
}
|
||||
}
|
||||
if (events.isEmpty()) {
|
||||
if (rows.isEmpty()) {
|
||||
Text(
|
||||
tr("filter.empty"),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
events.forEach { entry ->
|
||||
val visible = entry.key !in state.hiddenKeys
|
||||
rows.forEach { entry ->
|
||||
val visible = entry.key !in hiddenSet
|
||||
// entry.key is "source:id" (see calendarKey()); match sync errors on the
|
||||
// same (source, calendarId) pair the server already attaches to events.
|
||||
val entryId = entry.key.substringAfter(":")
|
||||
val hasSyncError = !groupMode && state.syncErrors.any { err ->
|
||||
err.source == entry.source && err.calendarId?.toString() == entryId
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
|
||||
if (hasSyncError) {
|
||||
Icon(
|
||||
Icons.Filled.WarningAmber,
|
||||
contentDescription = tr("filter.sync_error"),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(start = 8.dp).size(16.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
entry.name,
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Switch(
|
||||
checked = visible,
|
||||
onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) },
|
||||
if (entry.readOnly) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = tr("filter.read_only"),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 4.dp).size(15.dp),
|
||||
)
|
||||
}
|
||||
if (!groupMode) {
|
||||
val remDisabled = entry.key in state.reminderDisabledKeys
|
||||
IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) {
|
||||
Icon(
|
||||
if (remDisabled) Icons.Filled.NotificationsOff else Icons.Filled.Notifications,
|
||||
contentDescription = tr(if (remDisabled) "filter.reminders_on" else "filter.reminders_off"),
|
||||
tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
// Banish = permanently hide (syncs to the server); moves the
|
||||
// calendar into Settings, distinct from the local quick-hide.
|
||||
IconButton(onClick = { vm.setCalendarBanished(entry.key, banished = true) }) {
|
||||
Icon(
|
||||
Icons.Filled.Archive,
|
||||
contentDescription = tr("filter.banish"),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = visible,
|
||||
onCheckedChange = {
|
||||
if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it)
|
||||
else vm.setCalendarHidden(entry.key, !it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!groupMode && state.banishedKeys.isNotEmpty()) {
|
||||
Text(
|
||||
tr("filter.banished_footer"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Box(Modifier.padding(bottom = 24.dp))
|
||||
}
|
||||
|
||||
@@ -5,27 +5,37 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -35,15 +45,18 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -55,36 +68,110 @@ import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||
import com.scarriffle.calendarr.ui.LocalLang
|
||||
import com.scarriffle.calendarr.ui.accounts.AccountsScreen
|
||||
import com.scarriffle.calendarr.domain.model.Group
|
||||
import com.scarriffle.calendarr.ui.event.EventDetailScreen
|
||||
import com.scarriffle.calendarr.ui.event.EventEditorSheet
|
||||
import com.scarriffle.calendarr.ui.menu.MenuSheet
|
||||
import com.scarriffle.calendarr.ui.groups.GroupIcon
|
||||
import com.scarriffle.calendarr.ui.groups.GroupsScreen
|
||||
import com.scarriffle.calendarr.ui.menu.MenuScreen
|
||||
import com.scarriffle.calendarr.ui.profile.ProfileScreen
|
||||
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
|
||||
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS }
|
||||
private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
|
||||
|
||||
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CalendarScreen(
|
||||
onLogout: () -> Unit,
|
||||
onSwitchServer: () -> Unit,
|
||||
onSettingsChanged: (AppSettings) -> Unit,
|
||||
onSettingsSynced: () -> Unit,
|
||||
username: String = "",
|
||||
serverUrl: String = "",
|
||||
vm: CalendarViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
val lang = LocalLang.current
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
// Ask once for notification permission (Android 13+), then keep the OS
|
||||
// reminder alarms in sync with the visible events / muted-calendar set.
|
||||
val notifPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) {}
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33 &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.POST_NOTIFICATIONS
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(state.events, state.reminderDisabledKeys) {
|
||||
com.scarriffle.calendarr.notifications.NotificationScheduler.reschedule(
|
||||
context, state.events, state.reminderDisabledKeys, vm.defaultReminderMinutes
|
||||
)
|
||||
}
|
||||
// Re-check server-side calendar visibility whenever the app returns to the
|
||||
// foreground (e.g. after hiding/showing a calendar on the web or another
|
||||
// device); reloads only if something changed.
|
||||
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
|
||||
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) vm.onAppResumed()
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Contacts birthday import (mirrors iOS): request permission on demand; sync
|
||||
// this device's contact birthdays into the birthday calendar.
|
||||
val deviceName = android.os.Build.MODEL ?: "Android"
|
||||
fun runBirthdayImport() {
|
||||
vm.birthdaysSyncEnabled = true
|
||||
vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName)
|
||||
}
|
||||
val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { granted -> if (granted) runBirthdayImport() }
|
||||
fun startBirthdayImport() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.READ_CONTACTS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
if (vm.birthdaysSyncEnabled &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.READ_CONTACTS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) runBirthdayImport()
|
||||
}
|
||||
|
||||
var viewMenuOpen by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showFilter by remember { mutableStateOf(false) }
|
||||
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
||||
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
||||
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
||||
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var fabMenuOpen by remember { mutableStateOf(false) }
|
||||
var showBirthday by remember { mutableStateOf(false) }
|
||||
var birthdayCal by remember { mutableStateOf<com.scarriffle.calendarr.domain.model.LocalCalendar?>(null) }
|
||||
val birthdayScope = rememberCoroutineScope()
|
||||
val birthdayCalName = tr("birthday.calendar_name")
|
||||
|
||||
// Side navigation drawer (calendars + groups + view + menu).
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }
|
||||
androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) {
|
||||
if (drawerState.isOpen) vm.loadAllCalendars()
|
||||
}
|
||||
|
||||
// Continuous month scrolling
|
||||
val monthListState = rememberLazyListState()
|
||||
@@ -106,6 +193,18 @@ fun CalendarScreen(
|
||||
if (isMonth) todaySignal++ else vm.moveToToday()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawerContent(
|
||||
state = state, vm = vm, username = username, serverUrl = serverUrl,
|
||||
calendars = drawerCalendars,
|
||||
onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU },
|
||||
onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() },
|
||||
onClose = { drawerScope.launch { drawerState.close() } },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CompactTopBar(
|
||||
@@ -113,30 +212,67 @@ fun CalendarScreen(
|
||||
viewType = state.viewType,
|
||||
loading = state.isLoading || state.isBackgroundCaching,
|
||||
viewMenuOpen = viewMenuOpen,
|
||||
onMenu = { showMenu = true },
|
||||
showMenuButton = !state.hideMenuButton,
|
||||
onMenu = { drawerScope.launch { drawerState.open() } },
|
||||
onPrev = { goPrev() },
|
||||
onToday = { goToday() },
|
||||
onNext = { goNext() },
|
||||
onFilter = { showFilter = true },
|
||||
onViewMenuToggle = { viewMenuOpen = it },
|
||||
onSelectView = { vm.setViewType(it) },
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
// Tap = new event; long-press opens a small menu (new event / new
|
||||
// birthday). A transparent overlay carries the combined click so the
|
||||
// long-press is reliable on the Material FAB.
|
||||
Box(Modifier.navigationBarsPadding()) {
|
||||
FloatingActionButton(
|
||||
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
|
||||
onClick = {},
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
|
||||
onLongClick = { fabMenuOpen = true },
|
||||
),
|
||||
)
|
||||
DropdownMenu(expanded = fabMenuOpen, onDismissRequest = { fabMenuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(tr("cal.new_event")) },
|
||||
onClick = { fabMenuOpen = false; editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(tr("birthday.new")) },
|
||||
onClick = { fabMenuOpen = false; showBirthday = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
// Edge-to-edge: we place the system-bar insets ourselves (top bar gets
|
||||
// statusBarsPadding, the content column gets navigationBarsPadding) so
|
||||
// nothing hides behind the status/nav bars.
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) {
|
||||
state.error?.let { err ->
|
||||
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
||||
}
|
||||
if (state.syncErrors.isNotEmpty()) {
|
||||
SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors)
|
||||
}
|
||||
state.activeGroup?.let { g ->
|
||||
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// remember: stable callbacks keep the lazily composed week rows
|
||||
// skippable during scroll (fresh lambdas would recompose them all).
|
||||
CalendarBody(
|
||||
state = state,
|
||||
vm = vm,
|
||||
@@ -144,35 +280,41 @@ fun CalendarScreen(
|
||||
scrollToTodaySignal = todaySignal,
|
||||
monthJumpSignal = monthJumpSignal,
|
||||
monthJumpTarget = monthJumpTarget,
|
||||
onVisibleMonthChange = { visibleMonth = it },
|
||||
onEventClick = { detailEvent = it },
|
||||
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
|
||||
onDayLongPress = { date -> editor = EditorRequest(null, date) },
|
||||
onVisibleMonthChange = remember { { visibleMonth = it } },
|
||||
onEventClick = remember { { detailEvent = it } },
|
||||
onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } },
|
||||
onDayLongPress = remember { { date -> dayPreview = date } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sheets ----
|
||||
|
||||
if (showMenu) {
|
||||
MenuSheet(
|
||||
isAdmin = false,
|
||||
onDismiss = { showMenu = false },
|
||||
onProfile = { showMenu = false; overlay = Overlay.PROFILE },
|
||||
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
|
||||
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
|
||||
onSync = { showMenu = false; vm.syncWithServer() },
|
||||
onLogout = { showMenu = false; onLogout() },
|
||||
onSwitchServer = { showMenu = false; onSwitchServer() },
|
||||
dayPreview?.let { date ->
|
||||
DayPreviewDialog(
|
||||
date = date,
|
||||
events = remember(state.events, date) { vm.eventsOn(date, state.events) },
|
||||
onDismiss = { dayPreview = null },
|
||||
onEventClick = { ev -> dayPreview = null; detailEvent = ev },
|
||||
onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) },
|
||||
onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) },
|
||||
onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showFilter) {
|
||||
CalendarFilterSheet(
|
||||
events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) },
|
||||
vm = vm,
|
||||
onDismiss = { showFilter = false },
|
||||
if (showBirthday) {
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
|
||||
BirthdayDialog(
|
||||
calendar = birthdayCal,
|
||||
onDismiss = { showBirthday = false },
|
||||
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
|
||||
onImportContacts = { showBirthday = false; startBirthdayImport() },
|
||||
onSave = { name, date, yearKnown ->
|
||||
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
|
||||
showBirthday = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,8 +353,10 @@ fun CalendarScreen(
|
||||
request = req,
|
||||
writableCalendars = state.writableCalendars,
|
||||
onDismiss = { editor = null },
|
||||
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
|
||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
|
||||
defaultDurationMinutes = vm.defaultEventDurationMinutes,
|
||||
reminderDisabledKeys = state.reminderDisabledKeys,
|
||||
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders ->
|
||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error ->
|
||||
if (error == null) editor = null
|
||||
}
|
||||
},
|
||||
@@ -220,16 +364,33 @@ fun CalendarScreen(
|
||||
}
|
||||
|
||||
when (overlay) {
|
||||
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE })
|
||||
Overlay.SETTINGS -> SettingsScreen(
|
||||
Overlay.MENU -> MenuScreen(
|
||||
username = username,
|
||||
serverUrl = serverUrl,
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onProfile = { overlay = Overlay.PROFILE },
|
||||
onAppearance = { overlay = Overlay.SETTINGS },
|
||||
onAccounts = { overlay = Overlay.ACCOUNTS },
|
||||
onGroups = { overlay = Overlay.GROUPS },
|
||||
onSync = { overlay = Overlay.NONE; vm.syncWithServer() },
|
||||
onSwitchServer = onSwitchServer,
|
||||
onLogout = onLogout,
|
||||
)
|
||||
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.MENU })
|
||||
Overlay.SETTINGS -> SettingsScreen(
|
||||
onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() },
|
||||
onSettingsChanged = onSettingsChanged,
|
||||
onSettingsSynced = onSettingsSynced,
|
||||
)
|
||||
Overlay.ACCOUNTS -> AccountsScreen(
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onClose = { overlay = Overlay.MENU },
|
||||
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
|
||||
)
|
||||
Overlay.GROUPS -> GroupsScreen(
|
||||
onClose = { overlay = Overlay.MENU },
|
||||
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
|
||||
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
|
||||
)
|
||||
Overlay.NONE -> Unit
|
||||
}
|
||||
}
|
||||
@@ -283,6 +444,33 @@ private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> U
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Additive to [ErrorBanner]: the fetch as a whole succeeded, but one or more
|
||||
* enabled calendars failed to sync (e.g. expired credentials) and are showing
|
||||
* zero events with no other indication. Same visual language, no retry button
|
||||
* (retrying the whole range wouldn't target just the broken calendar).
|
||||
*/
|
||||
@Composable
|
||||
private fun SyncErrorBanner(errors: List<com.scarriffle.calendarr.data.SyncError>, onDismiss: () -> Unit) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
errors.forEach { err ->
|
||||
Text(
|
||||
"${err.name}: ${err.message}",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Row {
|
||||
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun loadingPlaceholder() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -296,20 +484,24 @@ private fun CompactTopBar(
|
||||
viewType: CalViewType,
|
||||
loading: Boolean,
|
||||
viewMenuOpen: Boolean,
|
||||
showMenuButton: Boolean,
|
||||
onMenu: () -> Unit,
|
||||
onPrev: () -> Unit,
|
||||
onToday: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onFilter: () -> Unit,
|
||||
onViewMenuToggle: (Boolean) -> Unit,
|
||||
onSelectView: (CalViewType) -> Unit,
|
||||
) {
|
||||
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY
|
||||
// Surface fills behind the status bar; the content column is inset below it.
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(Modifier.statusBarsPadding()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Menu (hamburger) on the left — the drawer opens from the left.
|
||||
if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
|
||||
CompactIcon(Icons.Filled.ChevronLeft, onPrev)
|
||||
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
|
||||
Text(tr("nav.today"), fontSize = 13.sp)
|
||||
@@ -331,7 +523,6 @@ private fun CompactTopBar(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
|
||||
Box {
|
||||
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
|
||||
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
|
||||
@@ -344,7 +535,11 @@ private fun CompactTopBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
|
||||
}
|
||||
Divider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,11 +555,36 @@ private fun CompactIcon(
|
||||
}
|
||||
}
|
||||
|
||||
/** Distinct calendars currently present in the cache, for the filter sheet. */
|
||||
@Composable
|
||||
private fun GroupBanner(group: Group, onExit: () -> Unit) {
|
||||
Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp))
|
||||
Text(
|
||||
"${tr("groups.view")}: ${group.name}",
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
TextButton(onClick = onExit) { Text(tr("group.switch.personal")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Distinct calendars in the cache for the filter sheet — from the UNFILTERED
|
||||
* cache (minus banished) so a locally quick-hidden calendar still shows up and
|
||||
* can be toggled back on. */
|
||||
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
|
||||
val st = vm.state.value
|
||||
return st.events
|
||||
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor) }
|
||||
val fromEvents = vm.knownCalendars()
|
||||
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) }
|
||||
// Event-derived entries first (current server colour / owner name), then the
|
||||
// full source list so calendars WITHOUT events in range still appear;
|
||||
// distinctBy keeps the event-derived entry when a calendar has both.
|
||||
return (fromEvents + vm.state.value.allCalendars)
|
||||
.distinctBy { it.key }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.scarriffle.calendarr.data.CalendarRepository
|
||||
import com.scarriffle.calendarr.data.SettingsStore
|
||||
import com.scarriffle.calendarr.data.SyncError
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||
import com.scarriffle.calendarr.domain.model.Group
|
||||
import com.scarriffle.calendarr.domain.model.GroupMember
|
||||
import com.scarriffle.calendarr.domain.model.LocalCalendar
|
||||
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
||||
import com.scarriffle.calendarr.util.Dates
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -34,12 +39,36 @@ data class CalendarUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isBackgroundCaching: Boolean = false,
|
||||
val error: String? = null,
|
||||
// Per-calendar sync failures from the last successful /events fetch (e.g.
|
||||
// expired CalDAV credentials) — the fetch as a whole succeeded, but one or
|
||||
// more enabled calendars silently returned nothing. Additive to `error`.
|
||||
val syncErrors: List<SyncError> = emptyList(),
|
||||
val weekStartsOnMonday: Boolean = true,
|
||||
val writableCalendars: List<WritableCalendar> = emptyList(),
|
||||
val hiddenKeys: Set<String> = emptySet(),
|
||||
val banishedKeys: Set<String> = emptySet(),
|
||||
// Calendars ("source:id") the user muted for reminders — events keep their
|
||||
// reminders but the scheduler skips them.
|
||||
val reminderDisabledKeys: Set<String> = emptySet(),
|
||||
// Group overlay: when non-null the calendar shows the group's combined view.
|
||||
val groups: List<Group> = emptyList(),
|
||||
val activeGroup: Group? = null,
|
||||
// Group overlay: full member list (for the filter) + per-member / group-cal
|
||||
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
|
||||
val activeGroupMembers: List<GroupMember> = emptyList(),
|
||||
val hiddenGroupKeys: Set<String> = emptySet(),
|
||||
// Full calendar list across all sources (loaded on demand) so the filter
|
||||
// shows every calendar, including ones with no events in the loaded range.
|
||||
val allCalendars: List<CalendarFilterEntry> = emptyList(),
|
||||
// Device-local: month view as horizontal paged (swipe) vs. scroll feed.
|
||||
val monthViewPaged: Boolean = false,
|
||||
// Device-local: hide the top-bar menu button (drawer opens via edge-swipe).
|
||||
val hideMenuButton: Boolean = false,
|
||||
)
|
||||
|
||||
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
|
||||
const val GROUP_CALENDAR_KEY = "gc"
|
||||
|
||||
@HiltViewModel
|
||||
class CalendarViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@@ -68,7 +97,8 @@ class CalendarViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadWritableCalendars()
|
||||
initialLoad()
|
||||
loadGroups()
|
||||
initialLoad(reconcile = true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,12 +106,15 @@ class CalendarViewModel @Inject constructor(
|
||||
* mark ready. Entering the app fully-loaded avoids the first-open jank of
|
||||
* loading a big batch while the user is already scrolling.
|
||||
*/
|
||||
private fun initialLoad() {
|
||||
private fun initialLoad(reconcile: Boolean = false) {
|
||||
val months = settingsStore.cacheMonths.toLong()
|
||||
val today = LocalDate.now().withDayOfMonth(1)
|
||||
val start = instant(today.minusMonths(months))
|
||||
val end = instant(today.plusMonths(months + 1))
|
||||
viewModelScope.launch {
|
||||
// Honour a calendar hidden/shown on the web BEFORE the first load so
|
||||
// the visibility filter is correct before events are rendered.
|
||||
if (reconcile) reconcileCalendarVisibility()
|
||||
loadRange(start, end, background = false)
|
||||
markReady()
|
||||
}
|
||||
@@ -94,9 +127,100 @@ class CalendarViewModel @Inject constructor(
|
||||
weekStartsOnMonday = s.weekStartsOnMonday,
|
||||
hiddenKeys = settingsStore.hiddenCalendarKeys,
|
||||
banishedKeys = settingsStore.banishedCalendarKeys,
|
||||
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
|
||||
monthViewPaged = settingsStore.monthViewPaged,
|
||||
hideMenuButton = settingsStore.hideMenuButton,
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-read the device-local view prefs (called when settings close). */
|
||||
fun refreshMonthViewMode() {
|
||||
_state.update { it.copy(
|
||||
monthViewPaged = settingsStore.monthViewPaged,
|
||||
hideMenuButton = settingsStore.hideMenuButton,
|
||||
) }
|
||||
}
|
||||
|
||||
// Device-local calendar order ("source:id"), mirrors the web cal_order.
|
||||
val calendarOrder: List<String> get() = settingsStore.calendarOrder
|
||||
fun setCalendarOrder(keys: List<String>) { settingsStore.calendarOrder = keys }
|
||||
|
||||
// ---- Contacts birthday import (mirrors the iOS BirthdaysImporter) ----
|
||||
|
||||
var birthdaysSyncEnabled: Boolean
|
||||
get() = settingsStore.birthdaysSyncEnabled
|
||||
set(value) { settingsStore.birthdaysSyncEnabled = value }
|
||||
|
||||
/** Mirror this device's contact birthdays into the (existing) birthday
|
||||
* calendar: reconcile rows scoped to this device's external_uid prefix
|
||||
* (add / update / delete), then report the device. No-op if disabled or
|
||||
* the birthday calendar hasn't been created. */
|
||||
fun syncContactBirthdays(contacts: List<com.scarriffle.calendarr.data.ContactBirthday>, deviceName: String) {
|
||||
if (!settingsStore.birthdaysSyncEnabled) return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val cal = repository.getLocalCalendars().firstOrNull { it.isBirthday && it.owned }
|
||||
?: return@runCatching
|
||||
val existing = repository.getBirthdayEntries(cal.id)
|
||||
val deviceId = settingsStore.birthdaysDeviceId
|
||||
val prefix = "contact:$deviceId:"
|
||||
val byExt = existing.filter { it.externalUid?.startsWith(prefix) == true }
|
||||
.associateBy { it.externalUid!! }
|
||||
val seen = mutableSetOf<String>()
|
||||
for (c in contacts) {
|
||||
val ext = prefix + c.contactId
|
||||
seen.add(ext)
|
||||
val (start, end) = birthdayRange(c.month, c.day, c.year)
|
||||
val match = byExt[ext]
|
||||
if (match != null) {
|
||||
val changed = match.title != c.name || match.month != c.month ||
|
||||
match.day != c.day || match.birthYear != c.year
|
||||
if (changed) repository.updateLocalEvent(
|
||||
uid = match.uid, title = c.name, start = start, end = end,
|
||||
isAllDay = true, location = "", description = "", color = null,
|
||||
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
|
||||
)
|
||||
} else {
|
||||
repository.createLocalEvent(
|
||||
calendarId = cal.id, title = c.name, start = start, end = end,
|
||||
isAllDay = true, location = "", description = "", color = null,
|
||||
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
|
||||
)
|
||||
}
|
||||
}
|
||||
byExt.filterKeys { it !in seen }.values.forEach { repository.deleteLocalEvent(it.uid) }
|
||||
repository.reportBirthdaySync(deviceId, deviceName, contacts.size)
|
||||
}
|
||||
loadVisible(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun birthdayRange(month: Int, day: Int, year: Int?): Pair<Instant, Instant> {
|
||||
val anchor = year ?: 2000 // leap-safe anchor for Feb 29 with unknown year
|
||||
val date = LocalDate.of(anchor, month, day)
|
||||
val start = date.atTime(12, 0).atZone(zone).toInstant()
|
||||
val end = date.plusDays(1).atTime(12, 0).atZone(zone).toInstant()
|
||||
return start to end
|
||||
}
|
||||
|
||||
/** Default duration (minutes) for a new event's end time. */
|
||||
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes
|
||||
|
||||
/** Default reminder offset (minutes before start), or -1 when off. */
|
||||
val defaultReminderMinutes: Int get() = settingsStore.loadSettings().defaultReminderMinutes ?: -1
|
||||
|
||||
/** Toggle a calendar's reminders without deleting any event reminders. */
|
||||
fun setCalendarRemindersDisabled(key: String, disabled: Boolean) {
|
||||
val keys = settingsStore.reminderDisabledCalendarKeys.toMutableSet()
|
||||
if (disabled) keys.add(key) else keys.remove(key)
|
||||
settingsStore.reminderDisabledCalendarKeys = keys
|
||||
_state.update { it.copy(reminderDisabledKeys = keys) }
|
||||
val parts = key.split(":")
|
||||
val source = parts.getOrNull(0) ?: return
|
||||
val id = parts.getOrNull(1)?.toIntOrNull() ?: return
|
||||
viewModelScope.launch { runCatching { repository.setCalendarRemindersEnabled(source, id, enabled = !disabled) } }
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
fun setViewType(type: CalViewType) {
|
||||
@@ -174,7 +298,7 @@ class CalendarViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
loadRange(start, end, background = false)
|
||||
loadRange(start, end, background = false, force = force)
|
||||
markReady()
|
||||
}
|
||||
}
|
||||
@@ -189,19 +313,49 @@ class CalendarViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
|
||||
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean) {
|
||||
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean, force: Boolean = false) {
|
||||
loadMutex.withLock {
|
||||
// Another load (e.g. the background prefetch) may have covered this range.
|
||||
if (isCached(start, end)) return
|
||||
// `force` bypasses this so callers can explicitly re-fetch an already-cached
|
||||
// range (e.g. to pick up a mutation) without wiping the whole cache first.
|
||||
if (!force && isCached(start, end)) return
|
||||
val group = _state.value.activeGroup
|
||||
val flag = if (background) "bg" else "fg"
|
||||
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
||||
runCatching { repository.fetchEvents(start, end) }
|
||||
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
|
||||
runCatching {
|
||||
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList<SyncError>()
|
||||
else repository.fetchEvents(start, end).let { it.events to it.errors }
|
||||
}
|
||||
.onSuccess { (events, errors) ->
|
||||
val (keepKeys, keepSources) = failedCalendarKeys(errors)
|
||||
mergeIntoCache(events, start, end, keepKeys, keepSources)
|
||||
refreshFromCache()
|
||||
_state.update { it.copy(syncErrors = errors) }
|
||||
}
|
||||
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
|
||||
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */
|
||||
private fun decorateGroup(events: List<CalEvent>): List<CalEvent> {
|
||||
val me = currentUserId
|
||||
return events.map { ev ->
|
||||
// Prefer the server-decorated title (group icon + owner prefix) so
|
||||
// web, iOS and Android render identically; fall back for old servers.
|
||||
val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() }
|
||||
if (serverTitle != null) return@map ev.copy(title = serverTitle)
|
||||
val prefix = when {
|
||||
ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: "
|
||||
ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: "
|
||||
else -> ""
|
||||
}
|
||||
if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title)
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s }
|
||||
|
||||
private fun markReady() {
|
||||
_ready.value = true
|
||||
}
|
||||
@@ -212,22 +366,76 @@ class CalendarViewModel @Inject constructor(
|
||||
return !cs.isAfter(start) && !ce.isBefore(end)
|
||||
}
|
||||
|
||||
private fun mergeIntoCache(newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant) {
|
||||
val retained = allCachedEvents.filter {
|
||||
!it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart)
|
||||
/**
|
||||
* Split sync errors into per-calendar keys and whole sources whose cached
|
||||
* events must NOT be evicted on a partial sync — stale data beats an empty
|
||||
* calendar. An error carrying a calendar_id protects just that calendar; an
|
||||
* account-level error without one (e.g. an HA / Google token-refresh
|
||||
* failure, which aborts the whole account fetch) protects every cached
|
||||
* calendar of that source.
|
||||
*/
|
||||
private fun failedCalendarKeys(errors: List<SyncError>): Pair<Set<String>, Set<String>> {
|
||||
val keys = mutableSetOf<String>()
|
||||
val sources = mutableSetOf<String>()
|
||||
for (err in errors) {
|
||||
val cid = err.calendarId
|
||||
if (cid != null) keys.add(calendarKey(err.source, cid.toString()))
|
||||
else sources.add(err.source)
|
||||
}
|
||||
return keys to sources
|
||||
}
|
||||
|
||||
private fun mergeIntoCache(
|
||||
newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant,
|
||||
keepKeysInRange: Set<String> = emptySet(),
|
||||
keepSourcesInRange: Set<String> = emptySet(),
|
||||
) {
|
||||
// Remove old events in the fetched range to avoid duplicates — but
|
||||
// PRESERVE events from calendars / sources that had sync errors so a
|
||||
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
|
||||
val retained = allCachedEvents.filter { ev ->
|
||||
val outsideRange = !ev.startDate.isBefore(rangeEnd) || !ev.endDate.isAfter(rangeStart)
|
||||
when {
|
||||
outsideRange -> true
|
||||
ev.source in keepSourcesInRange -> true
|
||||
keepKeysInRange.isEmpty() -> false
|
||||
else -> calendarKey(ev.source, ev.calendarId) in keepKeysInRange
|
||||
}
|
||||
}
|
||||
allCachedEvents = retained + newEvents
|
||||
// Only extend the cached range on a fully clean fetch. When some
|
||||
// calendars/sources failed, leave cachedStart/End unchanged so
|
||||
// isCached() stays false and they're retried on the next load rather
|
||||
// than being silently treated as "done" with empty data.
|
||||
if (keepKeysInRange.isEmpty() && keepSourcesInRange.isEmpty()) {
|
||||
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
|
||||
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFromCache() {
|
||||
val hidden = _state.value.hiddenKeys
|
||||
val banished = _state.value.banishedKeys
|
||||
val visible = allCachedEvents.filter { ev ->
|
||||
val st = _state.value
|
||||
// In group mode: server scopes/filters by privacy; locally honour the
|
||||
// per-member / group-calendar hide toggles (hiddenGroupKeys).
|
||||
val visible = if (st.activeGroup != null) {
|
||||
val hg = st.hiddenGroupKeys
|
||||
if (hg.isEmpty()) allCachedEvents
|
||||
else allCachedEvents.filter { ev ->
|
||||
when {
|
||||
ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg
|
||||
ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val hidden = st.hiddenKeys
|
||||
val banished = st.banishedKeys
|
||||
if (hidden.isEmpty() && banished.isEmpty()) allCachedEvents
|
||||
else allCachedEvents.filter { ev ->
|
||||
val key = calendarKey(ev.source, ev.calendarId)
|
||||
key !in hidden && key !in banished
|
||||
}
|
||||
}
|
||||
// Skip the state write (and resulting recomposition) when nothing changed.
|
||||
_state.update { if (it.events == visible) it else it.copy(events = visible) }
|
||||
}
|
||||
@@ -240,11 +448,61 @@ class CalendarViewModel @Inject constructor(
|
||||
|
||||
fun syncWithServer() {
|
||||
invalidateCache()
|
||||
initialLoad()
|
||||
loadGroups()
|
||||
initialLoad(reconcile = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the app returns to the foreground. Re-checks the server's
|
||||
* per-calendar visibility (a calendar may have been hidden/shown on the web
|
||||
* or another device meanwhile) and reloads only if something changed.
|
||||
*/
|
||||
fun onAppResumed() {
|
||||
viewModelScope.launch {
|
||||
if (reconcileCalendarVisibility()) {
|
||||
invalidateCache()
|
||||
loadGroups()
|
||||
initialLoad(reconcile = false) // just reconciled above
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the local **banished** set with the server's per-calendar
|
||||
* `sidebar_hidden` flags for external calendars (CalDAV / Google / HA).
|
||||
* Returns `true` if the set changed, so the caller can force a refetch — a
|
||||
* calendar re-enabled on the web has NO events in the cache (the server
|
||||
* excludes a hidden calendar's events entirely).
|
||||
*
|
||||
* NOTE: This deliberately drives `banishedKeys`, NOT `hiddenKeys`. The
|
||||
* quick-hide (`hiddenKeys`) is a device-local filter and must never be
|
||||
* overwritten from the server; only the "banish / permanently hide" state
|
||||
* maps to the server's `sidebar_hidden` (mirrors iOS).
|
||||
*/
|
||||
private suspend fun reconcileCalendarVisibility(): Boolean {
|
||||
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
|
||||
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
|
||||
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
|
||||
|
||||
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
|
||||
fun apply(source: String, id: Int, serverHidden: Boolean) {
|
||||
val key = calendarKey(source, id.toString())
|
||||
if (serverHidden) banished.add(key) else banished.remove(key)
|
||||
}
|
||||
caldav.forEach { acc -> acc.calendars?.forEach { apply("caldav", it.id, it.sidebarHidden) } }
|
||||
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
|
||||
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
|
||||
|
||||
if (banished == settingsStore.banishedCalendarKeys) return false
|
||||
settingsStore.banishedCalendarKeys = banished
|
||||
_state.update { it.copy(banishedKeys = banished) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearError() = _state.update { it.copy(error = null) }
|
||||
|
||||
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
|
||||
|
||||
// ---- Visibility filters ----
|
||||
|
||||
fun setCalendarHidden(key: String, hidden: Boolean) {
|
||||
@@ -262,6 +520,98 @@ class CalendarViewModel @Inject constructor(
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
/** Distinct calendars present in the full cache, IGNORING the device-local
|
||||
* quick-hide filter but excluding banished ones — for the filter sheet, so
|
||||
* a locally hidden calendar still appears there and can be toggled back on. */
|
||||
fun knownCalendars(): List<CalEvent> {
|
||||
val banished = _state.value.banishedKeys
|
||||
return allCachedEvents
|
||||
.distinctBy { calendarKey(it.source, it.calendarId) }
|
||||
.filter { calendarKey(it.source, it.calendarId) !in banished }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the full calendar list across all sources into [CalendarUiState.allCalendars]
|
||||
* so the filter shows every calendar, even ones with no events in the loaded
|
||||
* range. Called when the filter sheet opens. Read-only flag for shared local
|
||||
* calendars from owned/permission; banished calendars are dropped.
|
||||
*/
|
||||
fun loadAllCalendars() {
|
||||
viewModelScope.launch {
|
||||
val banished = _state.value.banishedKeys
|
||||
val entries = mutableListOf<CalendarFilterEntry>()
|
||||
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()).forEach { c ->
|
||||
entries += CalendarFilterEntry(
|
||||
key = calendarKey("local", c.id.toString()),
|
||||
name = if (c.owned) c.name else (c.sharedBy ?: c.name),
|
||||
color = c.color,
|
||||
source = "local",
|
||||
readOnly = !c.owned && c.permission != "read_write",
|
||||
)
|
||||
}
|
||||
runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()).forEach { acc ->
|
||||
acc.calendars.orEmpty().forEach { c ->
|
||||
entries += CalendarFilterEntry(calendarKey("caldav", c.id.toString()), c.name, c.color ?: acc.color, "caldav")
|
||||
}
|
||||
}
|
||||
runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc ->
|
||||
acc.calendars.orEmpty().forEach { c ->
|
||||
entries += CalendarFilterEntry(calendarKey("google", c.id.toString()), c.name, c.color ?: "#4285f4", "google")
|
||||
}
|
||||
}
|
||||
runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc ->
|
||||
acc.calendars.orEmpty().forEach { c ->
|
||||
entries += CalendarFilterEntry(calendarKey("homeassistant", c.id.toString()), c.name, c.color ?: "#46bdc6", "homeassistant")
|
||||
}
|
||||
}
|
||||
runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()).forEach { s ->
|
||||
entries += CalendarFilterEntry(calendarKey("ical", s.id.toString()), s.name, s.color, "ical")
|
||||
}
|
||||
_state.update { st -> st.copy(allCalendars = entries.filter { it.key !in banished }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's single birthday calendar, or null if not activated yet. */
|
||||
suspend fun birthdayCalendar(): LocalCalendar? =
|
||||
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
|
||||
.firstOrNull { it.isBirthday && it.owned }
|
||||
|
||||
/** The birthday calendar, creating the single one (named [name]) if none. */
|
||||
suspend fun ensureBirthdayCalendar(name: String): LocalCalendar? {
|
||||
birthdayCalendar()?.let { return it }
|
||||
return runCatching {
|
||||
repository.addLocalCalendar(name, "#E0407F", isBirthday = true)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a manual birthday: an all-day, yearly-recurring local event whose
|
||||
* age suffix and cake icon are added server-side. When the year is unknown
|
||||
* we anchor to 1970 and send no birth_year (so no age is shown).
|
||||
*/
|
||||
fun createBirthday(
|
||||
calendarId: Int, name: String, date: LocalDate, yearKnown: Boolean, onDone: () -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val anchor = LocalDate.of(if (yearKnown) date.year else 1970, date.monthValue, date.dayOfMonth)
|
||||
val start = Dates.startOfDay(anchor)
|
||||
val end = Dates.startOfDay(anchor.plusDays(1))
|
||||
runCatching {
|
||||
repository.createLocalEvent(
|
||||
calendarId, name, start, end, isAllDay = true,
|
||||
location = "", description = "", color = null,
|
||||
rrule = "FREQ=YEARLY", birthYear = if (yearKnown) date.year else null,
|
||||
)
|
||||
}.onSuccess { loadVisible(force = true); onDone() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Banish ("permanently hide") a calendar, or lift the banish. Unlike the
|
||||
* quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for
|
||||
* external calendars, matching iOS. Banishing also clears any local
|
||||
* quick-hide flag for the same key (redundant once banished).
|
||||
*/
|
||||
fun setCalendarBanished(key: String, banished: Boolean) {
|
||||
val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
|
||||
if (banished) add(key) else remove(key)
|
||||
@@ -273,6 +623,70 @@ class CalendarViewModel @Inject constructor(
|
||||
settingsStore.hiddenCalendarKeys = nextHidden
|
||||
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
|
||||
refreshFromCache()
|
||||
|
||||
val parts = key.split(":")
|
||||
val source = parts.getOrNull(0)
|
||||
val id = parts.getOrNull(1)?.toIntOrNull()
|
||||
if (source != null && id != null && source in listOf("caldav", "google", "homeassistant")) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.setCalendarSidebarHidden(source, id, banished) }
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Fehler beim Speichern") } }
|
||||
// Un-banishing re-enables the calendar on the server, but its
|
||||
// events were excluded while hidden — force a refetch so they
|
||||
// reappear without a manual sync.
|
||||
if (!banished) {
|
||||
invalidateCache()
|
||||
initialLoad(reconcile = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Groups ----
|
||||
|
||||
fun loadGroups() {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.getGroups() }
|
||||
.onSuccess { gs ->
|
||||
_state.update { st ->
|
||||
// If the active group was deleted elsewhere, drop back to personal.
|
||||
val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } }
|
||||
st.copy(groups = gs, activeGroup = stillActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Flip between personal and a group's combined overlay; reloads the wide window. */
|
||||
fun switchGroup(group: Group?) {
|
||||
if (_state.value.activeGroup?.id == group?.id) return
|
||||
_state.update {
|
||||
it.copy(activeGroup = group, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList())
|
||||
}
|
||||
invalidateCache()
|
||||
initialLoad()
|
||||
// Load the full member list (with server colours) for the filter sheet.
|
||||
if (group != null) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.getGroup(group.id) }
|
||||
.onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle a single member's calendar / the group calendar in the overlay. */
|
||||
fun setGroupKeyHidden(key: String, hidden: Boolean) {
|
||||
_state.update {
|
||||
val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) }
|
||||
it.copy(hiddenGroupKeys = next)
|
||||
}
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
/** Replace the group-overlay hidden set (bulk show/hide all). */
|
||||
fun setHiddenGroupKeys(keys: Set<String>) {
|
||||
_state.update { it.copy(hiddenGroupKeys = keys) }
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
// ---- Writable calendars ----
|
||||
@@ -286,9 +700,23 @@ class CalendarViewModel @Inject constructor(
|
||||
|
||||
// ---- Event mutations ----
|
||||
|
||||
/**
|
||||
* Re-fetch just the already-cached range after a create/update so the edit is
|
||||
* reflected everywhere it's currently loaded, without nuking and reloading the
|
||||
* whole ±cacheMonths window (which caused a full-screen freeze on every save).
|
||||
* Falls back to [initialLoad] if nothing was cached yet.
|
||||
*/
|
||||
private fun afterMutation() {
|
||||
invalidateCache()
|
||||
val start = cachedStart
|
||||
val end = cachedEnd
|
||||
if (start == null || end == null) {
|
||||
initialLoad()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
loadRange(start, end, background = false, force = true)
|
||||
markReady()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveEvent(
|
||||
@@ -302,20 +730,21 @@ class CalendarViewModel @Inject constructor(
|
||||
description: String,
|
||||
color: String?,
|
||||
isPrivate: Boolean,
|
||||
reminders: List<Int> = emptyList(),
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = runCatching {
|
||||
if (existing != null && existing.source == calendar.source) {
|
||||
when (existing.source) {
|
||||
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
||||
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
||||
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
||||
}
|
||||
} else {
|
||||
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
||||
}
|
||||
}
|
||||
result.onSuccess { afterMutation(); onResult(null) }
|
||||
@@ -326,9 +755,10 @@ class CalendarViewModel @Inject constructor(
|
||||
private suspend fun createForSource(
|
||||
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
||||
reminders: List<Int> = emptyList(),
|
||||
) {
|
||||
when (calendar.source) {
|
||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate)
|
||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
||||
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.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.CalendarViewWeek
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.ui.L10n
|
||||
import com.scarriffle.calendarr.ui.LocalLang
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
import java.time.LocalDate
|
||||
import java.time.format.TextStyle
|
||||
|
||||
/**
|
||||
* Long-press day preview (mirrors the iOS DayContextPreviewView): all-day
|
||||
* events as colored bars with tapered ends when they continue beyond this day,
|
||||
* timed events as dot + time + title rows, followed by quick actions.
|
||||
*/
|
||||
@Composable
|
||||
fun DayPreviewDialog(
|
||||
date: LocalDate,
|
||||
events: List<CalEvent>,
|
||||
onDismiss: () -> Unit,
|
||||
onEventClick: (CalEvent) -> Unit,
|
||||
onCreateEvent: () -> Unit,
|
||||
onOpenDay: () -> Unit,
|
||||
onOpenWeek: () -> Unit,
|
||||
) {
|
||||
val lang = LocalLang.current
|
||||
val sorted = remember(events) {
|
||||
events.sortedWith(compareByDescending<CalEvent> { it.isAllDay }.thenBy { it.startDate })
|
||||
}
|
||||
val weekdayAbbr = remember(date, lang) {
|
||||
date.dayOfWeek.getDisplayName(TextStyle.SHORT, L10n.locale(lang)).uppercase()
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(290.dp)
|
||||
.padding(vertical = 14.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Text(
|
||||
weekdayAbbr,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 6.dp, bottom = 3.dp),
|
||||
)
|
||||
Text(
|
||||
"${date.dayOfMonth}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
|
||||
if (sorted.isEmpty()) {
|
||||
Text(
|
||||
tr("cal.no_events_day"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
sorted.forEach { ev ->
|
||||
if (ev.isAllDay) {
|
||||
AllDayPreviewBar(event = ev, date = date, onClick = { onEventClick(ev) })
|
||||
} else {
|
||||
TimedPreviewRow(event = ev, lang = lang, onClick = { onEventClick(ev) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
PreviewAction(Icons.Filled.Add, tr("cal.new_event"), onCreateEvent)
|
||||
PreviewAction(Icons.Filled.CalendarViewWeek, tr("cal.show_in_week_view"), onOpenWeek)
|
||||
PreviewAction(Icons.Filled.WbSunny, tr("cal.show_in_day_view"), onOpenDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Unit) {
|
||||
val color = colorFromHex(event.effectiveColor)
|
||||
val startDay = localDate(event.startDate)
|
||||
val lastDay = localDate(event.endDate.minusSeconds(1)) // all-day end is exclusive
|
||||
val cLeft = startDay.isBefore(date)
|
||||
val cRight = lastDay.isAfter(date)
|
||||
val shape = remember(cLeft, cRight) { ChevronBarShape(cLeft, cRight) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 2.dp)
|
||||
.clip(shape)
|
||||
.background(color)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(
|
||||
start = if (cLeft) 14.dp else 8.dp,
|
||||
end = if (cRight) 14.dp else 8.dp,
|
||||
top = 4.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
) {
|
||||
EventLabel(
|
||||
event = event,
|
||||
color = color.contrastingTextColor(),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colorFromHex(event.effectiveColor)),
|
||||
)
|
||||
Text(
|
||||
timeLabel(event.startDate, lang),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp).width(42.dp),
|
||||
)
|
||||
EventLabel(
|
||||
event = event,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewAction(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar shape with tapered (pointed) ends on the sides where the event continues
|
||||
* beyond the shown day — port of the iOS ChevronBarShape.
|
||||
*/
|
||||
private class ChevronBarShape(
|
||||
private val continuesLeft: Boolean,
|
||||
private val continuesRight: Boolean,
|
||||
) : Shape {
|
||||
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
|
||||
val r = with(density) { 4.dp.toPx() }
|
||||
if (!continuesLeft && !continuesRight) {
|
||||
return Outline.Rounded(RoundRect(size.toRect(), CornerRadius(r)))
|
||||
}
|
||||
val t = with(density) { 8.dp.toPx() }.coerceAtMost(size.width / 2)
|
||||
val p = Path()
|
||||
if (continuesLeft && continuesRight) {
|
||||
p.moveTo(t, 0f)
|
||||
p.lineTo(size.width - t, 0f)
|
||||
p.lineTo(size.width, size.height / 2)
|
||||
p.lineTo(size.width - t, size.height)
|
||||
p.lineTo(t, size.height)
|
||||
p.lineTo(0f, size.height / 2)
|
||||
} else if (continuesLeft) {
|
||||
p.moveTo(t, 0f)
|
||||
p.lineTo(size.width, 0f)
|
||||
p.lineTo(size.width, size.height)
|
||||
p.lineTo(t, size.height)
|
||||
p.lineTo(0f, size.height / 2)
|
||||
} else {
|
||||
p.moveTo(0f, 0f)
|
||||
p.lineTo(size.width - t, 0f)
|
||||
p.lineTo(size.width, size.height / 2)
|
||||
p.lineTo(size.width - t, size.height)
|
||||
p.lineTo(0f, size.height)
|
||||
}
|
||||
p.close()
|
||||
return Outline.Generic(p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
|
||||
/**
|
||||
* Event label for the calendar grids: an optional leading cake icon for
|
||||
* birthday events, followed by the render title (which carries the
|
||||
* server-computed age). The icon inherits the text colour/size so the label
|
||||
* matches whatever bar it's dropped into.
|
||||
*/
|
||||
@Composable
|
||||
fun EventLabel(
|
||||
event: CalEvent,
|
||||
color: Color,
|
||||
fontSize: TextUnit = 10.sp,
|
||||
fontWeight: FontWeight = FontWeight.Medium,
|
||||
maxLines: Int = 1,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
if (event.isBirthday) {
|
||||
Icon(
|
||||
Icons.Filled.Cake,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier
|
||||
.size(fontSize.value.dp)
|
||||
.padding(end = 2.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
event.renderTitle,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
color = color,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -62,7 +64,8 @@ private const val MAX_LANES = 4
|
||||
private val DAY_NUM_H = 22.dp
|
||||
private val LANE_H = 15.dp
|
||||
private val LANE_SPACE = 2.dp
|
||||
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp
|
||||
// Bottom strip must fit the 8sp "+N"/"KW" labels without event bars overlapping them.
|
||||
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 16.dp
|
||||
|
||||
private enum class DividerEdge { NONE, TOP, BOTTOM }
|
||||
|
||||
@@ -78,7 +81,10 @@ private data class PlacedBar(
|
||||
|
||||
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
|
||||
|
||||
/** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */
|
||||
/** Month calendar with multi-day event bars (iOS-style). Two modes: a
|
||||
* continuous vertical scroll feed, or a horizontally-paged one-month-per-screen
|
||||
* grid (swipe left/right), toggled by [CalendarUiState.monthViewPaged]. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MonthView(
|
||||
state: CalendarUiState,
|
||||
@@ -97,11 +103,14 @@ fun MonthView(
|
||||
val mondayFirst = state.weekStartsOnMonday
|
||||
val today = LocalDate.now()
|
||||
|
||||
val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0))
|
||||
val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast))
|
||||
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
|
||||
val todayColor = colorFromHex(settings.todayColor)
|
||||
val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val outline = MaterialTheme.colorScheme.outline
|
||||
val onBackground = MaterialTheme.colorScheme.onBackground
|
||||
val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) }
|
||||
val labelColor = remember(settings.monthLabelColor, fallbackLabel) { colorFromHex(settings.monthLabelColor, fallbackLabel) }
|
||||
val gridColor = remember(outline, settings.lineContrast) { outline.copy(alpha = gridLineOpacity(settings.lineContrast)) }
|
||||
val secondaryText = remember(onBackground, settings.textContrast) { onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) }
|
||||
val todayColor = remember(settings.todayColor) { colorFromHex(settings.todayColor) }
|
||||
|
||||
// Column width measured once → no per-row BoxWithConstraints (smooth scrolling).
|
||||
val density = LocalDensity.current
|
||||
@@ -123,6 +132,39 @@ fun MonthView(
|
||||
|
||||
val todayIndex = remember(firstVisible) { weekIndexOf(today) }
|
||||
|
||||
// Month-paged mode bookkeeping (one page per calendar month).
|
||||
val firstMonth = remember { today.withDayOfMonth(1).minusMonths(MONTHS_BACK) }
|
||||
val monthCount = remember { (MONTHS_BACK + MONTHS_AHEAD + 1).toInt() }
|
||||
fun monthIndexOf(date: LocalDate): Int =
|
||||
ChronoUnit.MONTHS.between(firstMonth, date.withDayOfMonth(1)).toInt().coerceIn(0, monthCount - 1)
|
||||
val todayMonthIndex = remember(firstMonth) { monthIndexOf(today) }
|
||||
val pagerState = rememberPagerState(initialPage = todayMonthIndex) { monthCount }
|
||||
|
||||
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
|
||||
val dimPast = settings.dimPastEvents
|
||||
val cwLabel = tr("cal.cw")
|
||||
// remember: a fresh Instant per recomposition would invalidate every visible
|
||||
// WeekRow (the main source of scroll jank); minute precision is plenty here.
|
||||
val now = remember { java.time.Instant.now() }
|
||||
|
||||
// Route the title / prev-next / today signals to the active surface (pager or list).
|
||||
if (state.monthViewPaged) {
|
||||
LaunchedEffect(scrollToTodaySignal) {
|
||||
if (scrollToTodaySignal > 0) pagerState.animateScrollToPage(todayMonthIndex)
|
||||
}
|
||||
LaunchedEffect(monthJumpSignal) {
|
||||
if (monthJumpSignal > 0 && monthJumpTarget != null) pagerState.animateScrollToPage(monthIndexOf(monthJumpTarget))
|
||||
}
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
.map { firstMonth.plusMonths(it.toLong()) }
|
||||
.distinctUntilChanged()
|
||||
.collect { month ->
|
||||
onVisibleMonthChange(month)
|
||||
vm.ensureMonthLoaded(month)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
|
||||
LaunchedEffect(scrollToTodaySignal) {
|
||||
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
|
||||
@@ -141,10 +183,7 @@ fun MonthView(
|
||||
vm.ensureMonthLoaded(month)
|
||||
}
|
||||
}
|
||||
|
||||
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
|
||||
val dimPast = settings.dimPastEvents
|
||||
val now = java.time.Instant.now()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
||||
@@ -159,13 +198,19 @@ fun MonthView(
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
if (state.monthViewPaged) {
|
||||
// One month per page, six height-filling week rows, swipe to change month.
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
|
||||
) {
|
||||
items(weekCount) { index ->
|
||||
val weekStart = firstVisible.plusWeeks(index.toLong())
|
||||
) { page ->
|
||||
val month = firstMonth.plusMonths(page.toLong())
|
||||
val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
repeat(6) { w ->
|
||||
val weekStart = firstWeek.plusWeeks(w.toLong())
|
||||
WeekRow(
|
||||
rowModifier = Modifier.fillMaxWidth().weight(1f),
|
||||
weekStart = weekStart,
|
||||
today = today,
|
||||
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
||||
@@ -173,6 +218,36 @@ fun MonthView(
|
||||
dimPast = dimPast,
|
||||
now = now,
|
||||
lang = lang,
|
||||
cwLabel = cwLabel,
|
||||
dividerColor = dividerColor,
|
||||
gridColor = gridColor,
|
||||
labelColor = labelColor,
|
||||
secondaryText = secondaryText,
|
||||
todayColor = todayColor,
|
||||
onDayClick = onDayClick,
|
||||
onDayLongPress = onDayLongPress,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
|
||||
) {
|
||||
items(weekCount, key = { it }, contentType = { "week" }) { index ->
|
||||
val weekStart = firstVisible.plusWeeks(index.toLong())
|
||||
WeekRow(
|
||||
rowModifier = Modifier.fillMaxWidth().height(ROW_HEIGHT),
|
||||
weekStart = weekStart,
|
||||
today = today,
|
||||
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
||||
cellW = cellW,
|
||||
dimPast = dimPast,
|
||||
now = now,
|
||||
lang = lang,
|
||||
cwLabel = cwLabel,
|
||||
dividerColor = dividerColor,
|
||||
gridColor = gridColor,
|
||||
labelColor = labelColor,
|
||||
@@ -186,9 +261,11 @@ fun MonthView(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
rowModifier: Modifier,
|
||||
weekStart: LocalDate,
|
||||
today: LocalDate,
|
||||
weekEvents: List<CalEvent>,
|
||||
@@ -196,6 +273,7 @@ private fun WeekRow(
|
||||
dimPast: Boolean,
|
||||
now: java.time.Instant,
|
||||
lang: String,
|
||||
cwLabel: String,
|
||||
dividerColor: Color,
|
||||
gridColor: Color,
|
||||
labelColor: Color,
|
||||
@@ -208,11 +286,12 @@ private fun WeekRow(
|
||||
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
|
||||
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
|
||||
val rowStartsNewMonth = days[0].dayOfMonth == 1
|
||||
val cwLabel = tr("cal.cw")
|
||||
|
||||
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
|
||||
|
||||
Box(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
|
||||
// Scroll mode passes a fixed ROW_HEIGHT; paged mode passes weight(1f) so six
|
||||
// rows fill the screen. Event bars anchor to the top; the day cells fill height.
|
||||
Box(rowModifier) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
days.forEachIndexed { idx, day ->
|
||||
val edge = when {
|
||||
@@ -275,13 +354,11 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () ->
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
bar.event.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
EventLabel(
|
||||
event = bar.event,
|
||||
color = bar.textColor,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = bar.textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -38,6 +40,9 @@ import java.time.format.DateTimeFormatter
|
||||
|
||||
private val GUTTER = 48.dp
|
||||
|
||||
/** An all-day event laid out as a bar spanning columns [start]..[end] in lane [lane]. */
|
||||
private data class AllDayBar(val ev: CalEvent, val start: Int, val end: Int, val lane: Int)
|
||||
|
||||
@Composable
|
||||
fun TimeGridView(
|
||||
days: List<LocalDate>,
|
||||
@@ -47,16 +52,21 @@ fun TimeGridView(
|
||||
) {
|
||||
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
|
||||
val dimPast = LocalAppSettings.current.dimPastEvents
|
||||
val now = java.time.Instant.now()
|
||||
val lang = LocalLang.current
|
||||
val today = LocalDate.now()
|
||||
// Recomputed once per day (not on every recomposition/frame) — Instant.now()
|
||||
// was previously called fresh each recomposition, causing visible flicker in
|
||||
// the "is this now" past-dimming and wasted allocation.
|
||||
val now = remember(today) { java.time.Instant.now() }
|
||||
val lang = LocalLang.current
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Day headers (only for multi-day / week view)
|
||||
if (days.size > 1) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.width(GUTTER))
|
||||
val dayFmt = DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
||||
val dayFmt = remember(lang) {
|
||||
DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
||||
}
|
||||
days.forEach { day ->
|
||||
Text(
|
||||
dayFmt.format(day),
|
||||
@@ -70,16 +80,48 @@ fun TimeGridView(
|
||||
}
|
||||
}
|
||||
|
||||
// All-day row
|
||||
val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
||||
if (allDay.any { it.second.isNotEmpty() }) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
||||
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
|
||||
Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
|
||||
// All-day row: continuous bars spanning an event's start→end columns and
|
||||
// packed into lanes, instead of repeating a multi-day event once per day.
|
||||
val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
||||
if (perDay.any { it.isNotEmpty() }) {
|
||||
// event id -> [minCol, maxCol] within this week
|
||||
val range = LinkedHashMap<String, IntArray>()
|
||||
val evById = HashMap<String, CalEvent>()
|
||||
perDay.forEachIndexed { i, evs ->
|
||||
evs.forEach { ev ->
|
||||
evById[ev.id] = ev
|
||||
val r = range[ev.id]
|
||||
if (r == null) range[ev.id] = intArrayOf(i, i) else r[1] = i
|
||||
}
|
||||
allDay.forEach { (_, evs) ->
|
||||
Column(Modifier.weight(1f).padding(horizontal = 1.dp)) {
|
||||
evs.forEach { ev -> AllDayChip(ev, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) }
|
||||
}
|
||||
// earlier / longer first, then greedily pack into lanes
|
||||
val spans = range.entries
|
||||
.map { Triple(evById.getValue(it.key), it.value[0], it.value[1]) }
|
||||
.sortedWith(compareBy({ it.second }, { -(it.third - it.second) }))
|
||||
val laneEnd = ArrayList<Int>()
|
||||
val bars = ArrayList<AllDayBar>()
|
||||
for ((ev, s, e) in spans) {
|
||||
var lane = 0
|
||||
while (lane < laneEnd.size && laneEnd[lane] >= s) lane++
|
||||
if (lane == laneEnd.size) laneEnd.add(e) else laneEnd[lane] = e
|
||||
bars.add(AllDayBar(ev, s, e, lane))
|
||||
}
|
||||
val laneCount = (bars.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
||||
for (lane in 0 until laneCount) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
|
||||
if (lane == 0) Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
var col = 0
|
||||
bars.filter { it.lane == lane }.sortedBy { it.start }.forEach { bar ->
|
||||
if (bar.start > col) { Spacer(Modifier.weight((bar.start - col).toFloat())); col = bar.start }
|
||||
Box(Modifier.weight((bar.end - bar.start + 1).toFloat()).padding(horizontal = 1.dp)) {
|
||||
AllDayChip(bar.ev, dimmed = dimPast && bar.ev.endDate.isBefore(now), onClick = { onEventClick(bar.ev) })
|
||||
}
|
||||
col = bar.end + 1
|
||||
}
|
||||
if (col < days.size) Spacer(Modifier.weight((days.size - col).toFloat()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,13 +203,12 @@ private fun TimedEvent(
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
event.title,
|
||||
fontSize = 10.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
EventLabel(
|
||||
event = event,
|
||||
color = color.contrastingTextColor(),
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
)
|
||||
if (height > 36.dp) {
|
||||
Text(
|
||||
@@ -206,7 +247,7 @@ private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 3.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor())
|
||||
EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.compose.material.icons.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -99,7 +98,7 @@ fun EventDetailScreen(
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(event.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(event.renderTitle, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
if (event.calendarName.isNotBlank()) {
|
||||
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -126,13 +125,15 @@ fun EventDetailScreen(
|
||||
Text(tr("event.copy_title"))
|
||||
}
|
||||
if (canDelete) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Deliberately low-key (plain text button): destructive action
|
||||
// should be findable, not the loudest element on the screen.
|
||||
TextButton(
|
||||
onClick = { confirmDelete = true },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null)
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(tr("common.delete"))
|
||||
}
|
||||
|
||||
@@ -11,15 +11,21 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -43,8 +49,10 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.domain.model.ReminderOptions
|
||||
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
||||
import com.scarriffle.calendarr.ui.L10n
|
||||
import com.scarriffle.calendarr.ui.LocalLang
|
||||
@@ -69,7 +77,9 @@ fun EventEditorSheet(
|
||||
request: EditorRequest,
|
||||
writableCalendars: List<WritableCalendar>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit,
|
||||
defaultDurationMinutes: Int = 60,
|
||||
reminderDisabledKeys: Set<String> = emptySet(),
|
||||
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> Unit,
|
||||
) {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val context = LocalContext.current
|
||||
@@ -95,17 +105,20 @@ fun EventEditorSheet(
|
||||
var startTime by remember {
|
||||
mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0))
|
||||
}
|
||||
// New events default to start (09:00) + the user's default duration.
|
||||
val defaultEnd = request.date.atTime(9, 0).plusMinutes(defaultDurationMinutes.toLong())
|
||||
var endDate by remember {
|
||||
mutableStateOf(
|
||||
initialEnd?.let {
|
||||
val d = LocalDate.ofInstant(it, zone)
|
||||
if (template?.isAllDay == true) d.minusDays(1) else d
|
||||
} ?: request.date
|
||||
} ?: defaultEnd.toLocalDate()
|
||||
)
|
||||
}
|
||||
var endTime by remember {
|
||||
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0))
|
||||
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: defaultEnd.toLocalTime())
|
||||
}
|
||||
var reminders by remember { mutableStateOf(template?.reminders ?: emptyList<Int>()) }
|
||||
|
||||
val preselected = template?.let { ev ->
|
||||
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
|
||||
@@ -138,6 +151,8 @@ fun EventEditorSheet(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
@@ -231,13 +246,42 @@ fun EventEditorSheet(
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
|
||||
// Private (local calendars only)
|
||||
// Private + reminders (local calendars only)
|
||||
if (calendar?.source == "local") {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
|
||||
val remindersDisabled = calendar?.let {
|
||||
reminderDisabledKeys.contains(calendarKey(it.source, it.numericId.toString()))
|
||||
} ?: false
|
||||
Text(tr("event.reminders"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (remindersDisabled) {
|
||||
Text(
|
||||
tr("event.reminders_disabled"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
reminders.forEachIndexed { idx, min ->
|
||||
ReminderRow(
|
||||
minutes = min,
|
||||
enabled = !remindersDisabled,
|
||||
onChange = { v -> reminders = reminders.toMutableList().also { it[idx] = v } },
|
||||
onRemove = { reminders = reminders.toMutableList().also { it.removeAt(idx) } },
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.TextButton(
|
||||
enabled = !remindersDisabled,
|
||||
onClick = {
|
||||
val next = ReminderOptions.presets.firstOrNull { it !in reminders } ?: ReminderOptions.customDefault
|
||||
reminders = reminders + next
|
||||
},
|
||||
) { Text(tr("event.reminder_add")) }
|
||||
Spacer(Modifier.size(12.dp))
|
||||
}
|
||||
|
||||
// Color
|
||||
@@ -280,7 +324,8 @@ fun EventEditorSheet(
|
||||
start = startDate.atTime(startTime).atZone(zone).toInstant()
|
||||
end = endDate.atTime(endTime).atZone(zone).toInstant()
|
||||
}
|
||||
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local")
|
||||
val rem = if (cal.source == "local") reminders else emptyList()
|
||||
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem)
|
||||
},
|
||||
enabled = writableCalendars.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -290,3 +335,73 @@ fun EventEditorSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One reminder row: a preset dropdown plus, in custom mode, a number field +
|
||||
* unit dropdown. The value is always emitted as minutes-before-start. */
|
||||
@Composable
|
||||
private fun ReminderRow(
|
||||
minutes: Int,
|
||||
enabled: Boolean,
|
||||
onChange: (Int) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val isPreset = minutes in ReminderOptions.presets
|
||||
var presetMenu by remember { mutableStateOf(false) }
|
||||
var unitMenu by remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun label(min: Int): String =
|
||||
if (min == 0) tr("event.reminder_at_start")
|
||||
else ReminderOptions.split(min).let { (v, u) -> "$v ${tr(u.labelKey)} ${tr("event.reminder_before")}" }
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
OutlinedButton(onClick = { presetMenu = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (isPreset) label(minutes) else tr("event.reminder_custom"), modifier = Modifier.weight(1f))
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(expanded = presetMenu, onDismissRequest = { presetMenu = false }) {
|
||||
ReminderOptions.presets.forEach { p ->
|
||||
DropdownMenuItem(text = { Text(label(p)) }, onClick = { onChange(p); presetMenu = false })
|
||||
}
|
||||
DropdownMenuItem(text = { Text(tr("event.reminder_custom")) }, onClick = {
|
||||
if (minutes in ReminderOptions.presets) onChange(ReminderOptions.customDefault)
|
||||
presetMenu = false
|
||||
})
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRemove, enabled = enabled) {
|
||||
Icon(Icons.Filled.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
if (!isPreset) {
|
||||
val (value, unit) = ReminderOptions.split(minutes)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = value.toString(),
|
||||
onValueChange = { txt ->
|
||||
val n = txt.filter { it.isDigit() }.toIntOrNull()?.coerceAtLeast(1) ?: 1
|
||||
onChange(n * unit.mult)
|
||||
},
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.width(96.dp),
|
||||
)
|
||||
Box {
|
||||
OutlinedButton(onClick = { unitMenu = true }, enabled = enabled) {
|
||||
Text(tr(unit.labelKey))
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(expanded = unitMenu, onDismissRequest = { unitMenu = false }) {
|
||||
ReminderOptions.Unit.values().forEach { u ->
|
||||
DropdownMenuItem(text = { Text(tr(u.labelKey)) }, onClick = { onChange(value * u.mult); unitMenu = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(tr("event.reminder_before"), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package com.scarriffle.calendarr.ui.groups
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.navigationBarsPadding
|
||||
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.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.Celebration
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Flight
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Pets
|
||||
import androidx.compose.material.icons.filled.Restaurant
|
||||
import androidx.compose.material.icons.filled.School
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Work
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.scarriffle.calendarr.domain.model.Group
|
||||
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
|
||||
/**
|
||||
* Cross-platform group-icon keys (stored server-side) rendered as native
|
||||
* Material icons — consistent everywhere instead of OS-emoji that vary by
|
||||
* platform. Mirrors iOS GroupIcons / the web SVG set.
|
||||
*/
|
||||
object GroupIcons {
|
||||
val keys = listOf(
|
||||
"people", "home", "heart", "work", "school", "sports",
|
||||
"party", "pet", "travel", "music", "food", "star",
|
||||
)
|
||||
|
||||
fun vector(key: String?): ImageVector = when (key) {
|
||||
"people" -> Icons.Filled.People
|
||||
"home" -> Icons.Filled.Home
|
||||
"heart" -> Icons.Filled.Favorite
|
||||
"work" -> Icons.Filled.Work
|
||||
"school" -> Icons.Filled.School
|
||||
"sports" -> Icons.Filled.DirectionsRun
|
||||
"party" -> Icons.Filled.Celebration
|
||||
"pet" -> Icons.Filled.Pets
|
||||
"travel" -> Icons.Filled.Flight
|
||||
"music" -> Icons.Filled.MusicNote
|
||||
"food" -> Icons.Filled.Restaurant
|
||||
"star" -> Icons.Filled.Star
|
||||
else -> Icons.Filled.People
|
||||
}
|
||||
|
||||
fun isKey(s: String?): Boolean = s != null && s in keys
|
||||
}
|
||||
|
||||
/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */
|
||||
@Composable
|
||||
fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) {
|
||||
if (GroupIcons.isKey(icon)) {
|
||||
Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier,
|
||||
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
|
||||
} else if (!icon.isNullOrEmpty()) {
|
||||
Text(icon, modifier = modifier)
|
||||
} else {
|
||||
Icon(Icons.Filled.People, contentDescription = null, modifier = modifier,
|
||||
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupsScreen(
|
||||
onClose: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
onOpenGroupView: (Group) -> Unit = {},
|
||||
vm: GroupsViewModel = hiltViewModel(),
|
||||
) {
|
||||
var createOpen by remember { mutableStateOf(false) }
|
||||
var manageId by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(tr("groups.title")) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (vm.loading) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
return@Scaffold
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
||||
if (vm.groups.isEmpty()) {
|
||||
item {
|
||||
Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp))
|
||||
}
|
||||
}
|
||||
items(vm.groups, key = { it.id }) { g ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface)
|
||||
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
Text(g.name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
tr("groups.member_count", g.memberCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { manageId = g.id }) {
|
||||
Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (createOpen) {
|
||||
GroupEditSheet(
|
||||
vm = vm,
|
||||
existing = null,
|
||||
onDismiss = { createOpen = false },
|
||||
onSaved = { createOpen = false; onChanged() },
|
||||
)
|
||||
}
|
||||
|
||||
manageId?.let { id ->
|
||||
val existing = vm.groups.firstOrNull { it.id == id }
|
||||
GroupEditSheet(
|
||||
vm = vm,
|
||||
existing = existing,
|
||||
onDismiss = { manageId = null },
|
||||
onSaved = { manageId = null; onChanged() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Create (existing == null) or manage (existing != null) a group. */
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GroupEditSheet(
|
||||
vm: GroupsViewModel,
|
||||
existing: Group?,
|
||||
onDismiss: () -> Unit,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
val me = vm.currentUserId
|
||||
var name by remember { mutableStateOf(existing?.name ?: "") }
|
||||
var icon by remember { mutableStateOf(existing?.icon?.takeIf { GroupIcons.isKey(it) } ?: "people") }
|
||||
var selected by remember { mutableStateOf(setOf<Int>()) }
|
||||
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
|
||||
var detail by remember { mutableStateOf<Group?>(null) }
|
||||
var loaded by remember { mutableStateOf(existing == null) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
var memberColorTarget by remember { mutableStateOf<Pair<Int, String>?>(null) } // userId, current hex
|
||||
|
||||
// Manage: load full details (members + colours) and pre-fill selection.
|
||||
LaunchedEffect(existing?.id) {
|
||||
val id = existing?.id ?: return@LaunchedEffect
|
||||
val g = vm.groupDetail(id)
|
||||
detail = g
|
||||
if (g != null) {
|
||||
name = g.name
|
||||
icon = g.icon?.takeIf { GroupIcons.isKey(it) } ?: "people"
|
||||
val members = g.members.map { it.id }.filter { it != me }.toSet()
|
||||
existingMembers = members
|
||||
selected = members
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
if (existing == null) tr("groups.create") else tr("groups.manage"),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(tr("groups.name")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
GroupIcons.keys.forEach { ic ->
|
||||
val sel = ic == icon
|
||||
Box(
|
||||
Modifier.size(44.dp).clip(RoundedCornerShape(8.dp))
|
||||
.background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { icon = ic },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
GroupIcons.vector(ic),
|
||||
contentDescription = ic,
|
||||
tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
if (!loaded) {
|
||||
Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
|
||||
} else {
|
||||
vm.directory.forEach { u ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable {
|
||||
selected = if (u.id in selected) selected - u.id else selected + u.id
|
||||
}.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = u.id in selected, onCheckedChange = {
|
||||
selected = if (u.id in selected) selected - u.id else selected + u.id
|
||||
})
|
||||
Text(u.displayName, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Member colours (manage only)
|
||||
detail?.members?.takeIf { it.isNotEmpty() }?.let { members ->
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Divider()
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
members.forEach { m ->
|
||||
val hex = m.color ?: "#4285f4"
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex)))
|
||||
Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(20.dp))
|
||||
Button(onClick = {
|
||||
val n = name.trim()
|
||||
if (n.isEmpty()) return@Button
|
||||
if (existing == null) {
|
||||
vm.createGroup(n, icon, selected.toList()) { onSaved() }
|
||||
} else {
|
||||
vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() }
|
||||
}
|
||||
}, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(tr("event.save"))
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { confirmDelete = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memberColorTarget?.let { (userId, hex) ->
|
||||
ColorPickerDialog(
|
||||
initial = hex,
|
||||
title = tr("groups.member_color"),
|
||||
onDismiss = { memberColorTarget = null },
|
||||
onConfirm = { picked ->
|
||||
existing?.let { vm.setMemberColor(it.id, userId, picked) }
|
||||
// reflect locally
|
||||
detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) }
|
||||
memberColorTarget = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmDelete && existing != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text(tr("groups.delete")) },
|
||||
text = { Text(tr("groups.delete_confirm")) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) {
|
||||
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.scarriffle.calendarr.ui.groups
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.scarriffle.calendarr.data.CalendarRepository
|
||||
import com.scarriffle.calendarr.domain.model.DirectoryUser
|
||||
import com.scarriffle.calendarr.domain.model.Group
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class GroupsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
private set
|
||||
var groups by mutableStateOf<List<Group>>(emptyList())
|
||||
private set
|
||||
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
val currentUserId: Int get() = repository.currentUserId
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
groups = runCatching { repository.getGroups() }.getOrDefault(emptyList())
|
||||
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull()
|
||||
|
||||
fun createGroup(name: String, icon: String, memberIds: List<Int>, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.createGroup(name, memberIds, icon) }
|
||||
.onSuccess { load(); onDone() }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Save name/icon, then reconcile membership against [existingMemberIds]. */
|
||||
fun saveGroup(id: Int, name: String, icon: String, desired: Set<Int>, existing: Set<Int>, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.updateGroup(id, name, icon)
|
||||
for (uid in desired - existing) repository.addGroupMember(id, uid)
|
||||
for (uid in existing - desired) repository.removeGroupMember(id, uid)
|
||||
}
|
||||
.onSuccess { load(); onDone() }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
}
|
||||
|
||||
fun setMemberColor(groupId: Int, userId: Int, color: String) {
|
||||
viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } }
|
||||
}
|
||||
|
||||
fun deleteGroup(id: Int, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.deleteGroup(id) }
|
||||
.onSuccess { load(); onDone() }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.scarriffle.calendarr.ui.menu
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
|
||||
/** Full-screen menu / landing page reached from the drawer's gear: the hub for
|
||||
* Profile / Appearance / Accounts / Groups / Sync / Server / Logout. Each entry
|
||||
* opens its own full-screen page. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MenuScreen(
|
||||
username: String,
|
||||
serverUrl: String,
|
||||
onClose: () -> Unit,
|
||||
onProfile: () -> Unit,
|
||||
onAppearance: () -> Unit,
|
||||
onAccounts: () -> Unit,
|
||||
onGroups: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onSwitchServer: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(tr("nav.menu")) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
|
||||
},
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).navigationBarsPadding(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(username.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(username, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1)
|
||||
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
MenuRow(Icons.Filled.Person, tr("menu.profile"), onProfile)
|
||||
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
|
||||
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
|
||||
MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
|
||||
Divider(Modifier.padding(vertical = 4.dp))
|
||||
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
|
||||
MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer)
|
||||
MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout, destructive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit, destructive: Boolean = false) {
|
||||
val tint = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint)
|
||||
Spacer(Modifier.width(18.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.scarriffle.calendarr.ui.menu
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MenuSheet(
|
||||
isAdmin: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onProfile: () -> Unit,
|
||||
onAppearance: () -> Unit,
|
||||
onAccounts: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onSwitchServer: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
"Calendarr",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile)
|
||||
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
|
||||
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
|
||||
Divider(Modifier.padding(vertical = 4.dp))
|
||||
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
|
||||
Divider(Modifier.padding(vertical = 4.dp))
|
||||
MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout)
|
||||
MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(18.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,32 @@ package com.scarriffle.calendarr.ui.settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
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.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -36,6 +37,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -56,8 +58,11 @@ import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.toHex
|
||||
|
||||
private val PALETTE = listOf(
|
||||
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
|
||||
// Canonical default colours (single source for the reset buttons).
|
||||
private val DEFAULT_COLORS = mapOf(
|
||||
"primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4",
|
||||
"text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52",
|
||||
"month_divider_color" to "#7090C0", "month_label_color" to "#7090C0",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -70,7 +75,6 @@ fun SettingsScreen(
|
||||
) {
|
||||
val initialSettings = LocalAppSettings.current
|
||||
var settings by remember { mutableStateOf(initialSettings) }
|
||||
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
|
||||
|
||||
fun update(newSettings: AppSettings) {
|
||||
settings = newSettings
|
||||
@@ -78,6 +82,10 @@ fun SettingsScreen(
|
||||
vm.apply(newSettings, onSettingsSynced)
|
||||
}
|
||||
|
||||
// Per-row sync toggle: enabling adopts this device's current value.
|
||||
fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) }
|
||||
fun synced(key: String) = vm.syncFlags[key] == true
|
||||
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -91,99 +99,254 @@ fun SettingsScreen(
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
|
||||
ProfileChapter(vm)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.calview"))
|
||||
ChipRow(
|
||||
options = CalViewType.entries.map { it.key to tr("view.${it.key}") },
|
||||
selected = settings.defaultView,
|
||||
onSelect = { update(settings.copy(defaultView = it)) },
|
||||
// Global "sync everything" master switch.
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = vm.syncableKeys.all { vm.syncFlags[it] == true },
|
||||
onCheckedChange = { on -> vm.setAllSync(on, settings, onSettingsSynced) },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Section(tr("settings.firstweekday"))
|
||||
ChipRow(
|
||||
options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
|
||||
selected = settings.weekStartDay,
|
||||
onSelect = { update(settings.copy(weekStartDay = it)) },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) })
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.language"))
|
||||
ChipRow(
|
||||
options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
|
||||
selected = settings.language,
|
||||
onSelect = { update(settings.copy(language = it)) },
|
||||
ProfileChapter(vm)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// ---- Termine (synced) ----
|
||||
Section(tr("settings.calview"))
|
||||
SyncDropdownRow(
|
||||
tr("settings.default_duration"),
|
||||
listOf("15" to "15 min", "30" to "30 min", "45" to "45 min", "60" to "1 h", "90" to "1.5 h", "120" to "2 h"),
|
||||
settings.defaultEventDurationMinutes.toString(),
|
||||
{ update(settings.copy(defaultEventDurationMinutes = it.toInt())) },
|
||||
synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") },
|
||||
)
|
||||
SyncDropdownRow(
|
||||
tr("settings.defaultreminder"),
|
||||
reminderOptions(),
|
||||
(settings.defaultReminderMinutes ?: -1).toString(),
|
||||
{ update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) },
|
||||
synced("default_reminder_minutes"), { toggle("default_reminder_minutes") },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// ---- Ansicht (synced) ----
|
||||
Section(tr("settings.appearance"))
|
||||
SyncDropdownRow(
|
||||
tr("settings.defaultview"),
|
||||
CalViewType.entries.map { it.key to tr("view.${it.key}") },
|
||||
settings.defaultView,
|
||||
{ update(settings.copy(defaultView = it)) },
|
||||
synced("default_view"), { toggle("default_view") },
|
||||
)
|
||||
SyncDropdownRow(
|
||||
tr("settings.firstweekday"),
|
||||
listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
|
||||
settings.weekStartDay,
|
||||
{ update(settings.copy(weekStartDay = it)) },
|
||||
synced("week_start_day"), { toggle("week_start_day") },
|
||||
)
|
||||
SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") })
|
||||
SyncDropdownRow(
|
||||
tr("settings.month_mode"),
|
||||
listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")),
|
||||
settings.monthViewPaged.toString(),
|
||||
{ update(settings.copy(monthViewPaged = it.toBoolean())) },
|
||||
synced("month_view_paged"), { toggle("month_view_paged") },
|
||||
)
|
||||
SyncDropdownRow(
|
||||
tr("settings.hourheight"),
|
||||
listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")),
|
||||
settings.hourHeight.toString(),
|
||||
{ update(settings.copy(hourHeight = it.toInt())) },
|
||||
synced("hour_height"), { toggle("hour_height") },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// ---- Farben (synced) ----
|
||||
Section(tr("settings.colors"))
|
||||
ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) }
|
||||
ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) }
|
||||
ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) }
|
||||
ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) }
|
||||
ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) }
|
||||
SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
|
||||
SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
|
||||
SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
|
||||
SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
|
||||
SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) }
|
||||
SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) }
|
||||
SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) }
|
||||
SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) }
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.hourheight"))
|
||||
ChipRow(
|
||||
options = listOf(
|
||||
"28" to tr("settings.hourheight.compact"),
|
||||
"44" to tr("settings.hourheight.normal"),
|
||||
"60" to tr("settings.hourheight.comfort"),
|
||||
"80" to tr("settings.hourheight.large"),
|
||||
),
|
||||
selected = settings.hourHeight.toString(),
|
||||
onSelect = { update(settings.copy(hourHeight = it.toInt())) },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Section(tr("settings.textcontrast"))
|
||||
ChipRow(
|
||||
options = listOf(
|
||||
"1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"),
|
||||
"3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"),
|
||||
),
|
||||
selected = settings.textContrast.toString(),
|
||||
onSelect = { update(settings.copy(textContrast = it.toInt())) },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Section(tr("settings.linecontrast"))
|
||||
ChipRow(
|
||||
options = listOf(
|
||||
"1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"),
|
||||
"3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"),
|
||||
),
|
||||
selected = settings.lineContrast.toString(),
|
||||
onSelect = { update(settings.copy(lineContrast = it.toInt())) },
|
||||
// ---- Cache (synced) ----
|
||||
SyncDropdownRow(
|
||||
tr("settings.cache.range"),
|
||||
listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
|
||||
settings.cacheMonths.toString(),
|
||||
{ update(settings.copy(cacheMonths = it.toInt())) },
|
||||
synced("cache_months"), { toggle("cache_months") },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.cache.title"))
|
||||
ChipRow(
|
||||
options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
|
||||
selected = cacheMonths.toString(),
|
||||
onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() },
|
||||
)
|
||||
// ---- Gerät (device-local: language + contrast) ----
|
||||
Section(tr("settings.device"))
|
||||
DeviceDropdownRow(
|
||||
tr("settings.language"),
|
||||
listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
|
||||
settings.language,
|
||||
) { update(settings.copy(language = it)) }
|
||||
DeviceDropdownRow(
|
||||
tr("settings.textcontrast"),
|
||||
listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")),
|
||||
settings.textContrast.toString(),
|
||||
) { update(settings.copy(textContrast = it.toInt())) }
|
||||
DeviceDropdownRow(
|
||||
tr("settings.linecontrast"),
|
||||
listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")),
|
||||
settings.lineContrast.toString(),
|
||||
) { update(settings.copy(lineContrast = it.toInt())) }
|
||||
var hideMenu by remember { mutableStateOf(vm.hideMenuButton) }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.size(44.dp))
|
||||
Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it })
|
||||
}
|
||||
Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp))
|
||||
Spacer(Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderOptions(): List<Pair<String, String>> = listOf(
|
||||
"-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min",
|
||||
"30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
|
||||
/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */
|
||||
|
||||
/** Leading per-row sync toggle: highlighted = synced across devices. */
|
||||
@Composable
|
||||
private fun SyncIcon(on: Boolean, onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = tr("settings.sync_this"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Compact dropdown trigger (a small outlined chip) — much shorter than a full
|
||||
// OutlinedTextField, so the settings rows aren't inflated.
|
||||
@Composable
|
||||
private fun Dropdown(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected
|
||||
Box {
|
||||
Row(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
|
||||
.clickable { expanded = true }
|
||||
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(selectedLabel, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { (key, label) ->
|
||||
DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncDropdownRow(
|
||||
label: String,
|
||||
options: List<Pair<String, String>>,
|
||||
selected: String,
|
||||
onSelect: (String) -> Unit,
|
||||
synced: Boolean,
|
||||
onToggleSync: () -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SyncIcon(synced, onToggleSync)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Dropdown(options, selected, onSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceDropdownRow(
|
||||
label: String,
|
||||
options: List<Pair<String, String>>,
|
||||
selected: String,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.size(44.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Dropdown(options, selected, onSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SyncIcon(synced, onToggleSync)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncColorRow(
|
||||
syncKey: String,
|
||||
label: String,
|
||||
current: String,
|
||||
synced: Boolean,
|
||||
onToggleSync: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SyncIcon(synced, onToggleSync)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Box(
|
||||
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
|
||||
.clickable { showPicker = true },
|
||||
)
|
||||
TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
||||
Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
if (showPicker) {
|
||||
ColorPickerDialog(
|
||||
initial = current,
|
||||
title = label,
|
||||
onDismiss = { showPicker = false },
|
||||
onConfirm = { showPicker = false; onPick(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProfileChapter(vm: SettingsViewModel) {
|
||||
val savedLabel = tr("settings.saved")
|
||||
@@ -210,6 +373,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange)
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
|
||||
vm.profileMessage?.let {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
@@ -218,11 +389,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.privacy"))
|
||||
ChipRow(
|
||||
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
|
||||
selected = vm.privateVisibility,
|
||||
onSelect = vm::changePrivateVisibility,
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Dropdown(
|
||||
listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
|
||||
vm.privateVisibility,
|
||||
vm::changePrivateVisibility,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
@@ -256,35 +430,3 @@ private fun CalendarDropdown(vm: SettingsViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
options.forEach { (key, label) ->
|
||||
FilterChip(selected = key == selected, onClick = { onSelect(key) }, label = { Text(label) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorRow(label: String, current: String, onPick: (String) -> Unit) {
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable { showPicker = true }.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape))
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (showPicker) {
|
||||
ColorPickerDialog(
|
||||
initial = current,
|
||||
title = label,
|
||||
onDismiss = { showPicker = false },
|
||||
onConfirm = { showPicker = false; onPick(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,18 +19,40 @@ class SettingsViewModel @Inject constructor(
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Persist locally immediately, then sync to the server in the background. */
|
||||
/** Persist locally immediately, then push the synced values to the server. */
|
||||
fun apply(settings: AppSettings, onSynced: () -> Unit) {
|
||||
settingsStore.saveSettings(settings)
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.updateSettings(settings) }
|
||||
runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
|
||||
onSynced()
|
||||
}
|
||||
}
|
||||
|
||||
var cacheMonths: Int
|
||||
get() = settingsStore.cacheMonths
|
||||
set(value) { settingsStore.cacheMonths = value }
|
||||
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
|
||||
var hideMenuButton: Boolean
|
||||
get() = settingsStore.hideMenuButton
|
||||
set(value) { settingsStore.hideMenuButton = value }
|
||||
|
||||
// ---- Per-setting sync flags ----
|
||||
|
||||
val syncableKeys: List<String> get() = settingsStore.syncableKeys
|
||||
var syncFlags by mutableStateOf(settingsStore.loadSyncFlags())
|
||||
private set
|
||||
|
||||
/** Toggle one setting's flag. Enabling adopts this device's current value
|
||||
* (pushes the synced values up); disabling keeps the value local. */
|
||||
fun toggleSync(key: String, current: AppSettings, onSynced: () -> Unit) {
|
||||
settingsStore.setSyncFlag(key, !(syncFlags[key] ?: false))
|
||||
syncFlags = settingsStore.loadSyncFlags()
|
||||
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
|
||||
}
|
||||
|
||||
/** Global "sync everything" switch. */
|
||||
fun setAllSync(on: Boolean, current: AppSettings, onSynced: () -> Unit) {
|
||||
settingsStore.setAllSyncFlags(on)
|
||||
syncFlags = settingsStore.loadSyncFlags()
|
||||
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
|
||||
}
|
||||
|
||||
// ---- Profile chapter (server-backed) ----
|
||||
|
||||
@@ -38,6 +60,8 @@ class SettingsViewModel @Inject constructor(
|
||||
var loginName by mutableStateOf("")
|
||||
var email by mutableStateOf("")
|
||||
private set
|
||||
var directoryHidden by mutableStateOf(false)
|
||||
private set
|
||||
var privateVisibility by mutableStateOf("busy")
|
||||
private set
|
||||
var groupVisibleId by mutableStateOf(0) // 0 = none
|
||||
@@ -51,6 +75,7 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun onDisplayNameChange(v: String) { displayName = v }
|
||||
fun onEmailChange(v: String) { email = v }
|
||||
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
|
||||
|
||||
private fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
@@ -58,13 +83,16 @@ class SettingsViewModel @Inject constructor(
|
||||
displayName = p.displayName ?: p.username
|
||||
loginName = p.username
|
||||
email = p.email ?: ""
|
||||
directoryHidden = p.directoryHidden
|
||||
}
|
||||
runCatching { repository.getSettings() }.onSuccess { s ->
|
||||
privateVisibility = s.privateEventVisibility
|
||||
groupVisibleId = s.groupVisibleCalendarId ?: 0
|
||||
}
|
||||
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
|
||||
ownLocalCalendars = cals.filter { it.owned && !it.group }
|
||||
// A birthday calendar may be shared directly, but never stand in
|
||||
// as the group-visible personal calendar.
|
||||
ownLocalCalendars = cals.filter { it.owned && !it.group && !it.isBirthday }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +104,7 @@ class SettingsViewModel @Inject constructor(
|
||||
displayName = displayName.trim().ifEmpty { null },
|
||||
username = null,
|
||||
email = email.trim(),
|
||||
directoryHidden = directoryHidden,
|
||||
)
|
||||
}
|
||||
.onSuccess { profileMessage = savedLabel }
|
||||
|
||||
@@ -1,58 +1,74 @@
|
||||
package com.scarriffle.calendarr.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.domain.model.AppSettings
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
|
||||
/**
|
||||
* The Calendarr brand accent — the green from the iOS `AccentColor` asset
|
||||
* (#20A050). This drives the global control tint (buttons, FAB, switches,
|
||||
* top bar) regardless of the server's per-calendar colours, matching iOS
|
||||
* where the app tint is fixed and `primary_color` only styles calendar
|
||||
* elements (e.g. the "today" highlight, read from [AppSettings]).
|
||||
*/
|
||||
/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
|
||||
* primary colour is unset. */
|
||||
val BrandGreen = Color(0xFF20A050)
|
||||
|
||||
/**
|
||||
* Fully dynamic theme: every colour is derived from [AppSettings] so the whole
|
||||
* app follows the user's palette (matching the web client). primary/accent tint
|
||||
* the controls; background/text/line drive `background`/`onBackground`/`outline`,
|
||||
* which the calendar views already read (grid, secondary text). "today", divider
|
||||
* and label colours are read directly in the views.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarrTheme(
|
||||
settings: AppSettings = AppSettings(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val primary = BrandGreen
|
||||
val primary = colorFromHex(settings.primaryColor, BrandGreen)
|
||||
val accent = colorFromHex(settings.accentColor, primary)
|
||||
val bg = colorFromHex(settings.backgroundColor, Color(0xFF000000))
|
||||
val onBg = colorFromHex(settings.textColor, Color(0xFFF2F2F7))
|
||||
val line = colorFromHex(settings.lineColor, Color(0xFF3A3A52))
|
||||
|
||||
val surface = lerp(bg, Color.White, 0.10f)
|
||||
val surfaceVariant = lerp(bg, Color.White, 0.17f)
|
||||
val container = lerp(primary, Color.Black, 0.55f)
|
||||
val onContainer = lerp(primary, Color.White, 0.75f)
|
||||
|
||||
val container = Color(0xFF14532D)
|
||||
val onContainer = Color(0xFFB7F0C6)
|
||||
val colors = darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = primary.contrastingTextColor(),
|
||||
primaryContainer = container,
|
||||
onPrimaryContainer = onContainer,
|
||||
secondary = primary,
|
||||
onSecondary = primary.contrastingTextColor(),
|
||||
secondary = accent,
|
||||
onSecondary = accent.contrastingTextColor(),
|
||||
secondaryContainer = container,
|
||||
onSecondaryContainer = onContainer,
|
||||
tertiary = primary,
|
||||
onTertiary = primary.contrastingTextColor(),
|
||||
tertiary = accent,
|
||||
onTertiary = accent.contrastingTextColor(),
|
||||
tertiaryContainer = container,
|
||||
onTertiaryContainer = onContainer,
|
||||
surfaceTint = primary,
|
||||
background = Color(0xFF000000),
|
||||
onBackground = Color(0xFFF2F2F7),
|
||||
surface = Color(0xFF1C1C1E),
|
||||
onSurface = Color(0xFFF2F2F7),
|
||||
surfaceVariant = Color(0xFF2C2C2E),
|
||||
onSurfaceVariant = Color(0xFFBEBEC4),
|
||||
outline = Color(0xFF3A3A3C),
|
||||
background = bg,
|
||||
onBackground = onBg,
|
||||
surface = surface,
|
||||
onSurface = onBg,
|
||||
surfaceVariant = surfaceVariant,
|
||||
onSurfaceVariant = onBg.copy(alpha = 0.7f),
|
||||
outline = line,
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
typography = Typography(),
|
||||
// extraSmall drives DropdownMenu containers (default 4dp looks boxy);
|
||||
// rounder popups to match the iOS look.
|
||||
shapes = Shapes(extraSmall = RoundedCornerShape(14.dp)),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -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="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -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="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<color name="splash_bg">#000000</color>
|
||||
<color name="ic_launcher_background">#16703D</color>
|
||||
|
||||
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Black window/system bars (matches the app background). -->
|
||||
|
||||
5
fastlane/metadata/android/de-DE/changelogs/2.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
* Pro-Einstellung-Synchronisierung mit einheitlicher Einstellungstabelle
|
||||
* Neue linke Seitenleiste: Kalender ein-/ausblenden & sortieren, Gruppenwechsel, Schnellmenü
|
||||
* Geburtstage aus den Kontakten importieren
|
||||
* Voll dynamische Theme-Farben (8 einstellbare Farben mit Reset)
|
||||
* Fix: mehrtägige Ganztags-Termine werden in Wochen-/Tagesansicht durchgehend dargestellt
|
||||
19
fastlane/metadata/android/de-DE/full_description.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Calendarr ist die Android-App für deinen eigenen, selbst gehosteten Calendarr-Server — ein privater, werbefreier Kalender, dessen Daten auf deiner Infrastruktur bleiben.
|
||||
|
||||
Binde die Quellen ein, die du ohnehin nutzt:
|
||||
* Lokale Kalender auf dem Server
|
||||
* CalDAV-Konten
|
||||
* iCal-/ICS-Abos
|
||||
* Google Kalender
|
||||
* Home Assistant
|
||||
|
||||
Funktionen:
|
||||
* Monats-, Wochen-, Tages-, Quartals- und Terminansicht
|
||||
* Seitenleiste zum Ein-/Ausblenden und Sortieren von Kalendern, Gruppenwechsel und Zugriff aufs Menü
|
||||
* Kalender mit anderen teilen und in Gruppen organisieren
|
||||
* Geburtstage: eigener Geburtstagskalender mit Alter und Erinnerungen, optional aus deinen Kontakten importiert
|
||||
* Pro-Einstellung-Synchronisierung über Geräte — du wählst genau, welche Einstellung dir über Web, iOS und Android folgt
|
||||
* Frei anpassbare Farben und dunkle Oberfläche
|
||||
* Termin-Erinnerungen / Benachrichtigungen
|
||||
|
||||
Calendarr benötigt Zugriff auf einen (selbst gehosteten) Calendarr-Server. Server-URL eingeben, dann anmelden.
|
||||
BIN
fastlane/metadata/android/de-DE/images/icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
fastlane/metadata/android/de-DE/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Kalender-App für deinen selbst gehosteten Calendarr-Server
|
||||
1
fastlane/metadata/android/de-DE/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Calendarr
|
||||
5
fastlane/metadata/android/en-US/changelogs/2.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
* Per-setting cross-device sync with a unified settings table
|
||||
* New left navigation drawer: show/hide & reorder calendars, group switch, quick menu
|
||||
* Import birthdays from your contacts
|
||||
* Fully dynamic theme colours (8 configurable colours with reset)
|
||||
* Fix: multi-day all-day events now span correctly in week/day view
|
||||
19
fastlane/metadata/android/en-US/full_description.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Calendarr is the Android client for your own self-hosted Calendarr server — a private, ad-free calendar whose data stays on your infrastructure.
|
||||
|
||||
Bring together the sources you already use:
|
||||
* Local calendars on the server
|
||||
* CalDAV accounts
|
||||
* iCal / ICS subscriptions
|
||||
* Google Calendar
|
||||
* Home Assistant
|
||||
|
||||
Features:
|
||||
* Month, week, day, quarter and agenda views
|
||||
* A left drawer to show/hide and reorder your calendars, switch groups, and reach the menu
|
||||
* Share calendars with other users and organise them in groups
|
||||
* Birthdays: a dedicated birthday calendar with age and reminders, optionally imported from your contacts
|
||||
* Per-setting cross-device sync — choose exactly which settings follow you across web, iOS and Android
|
||||
* Fully customisable colours and a dark interface
|
||||
* Event reminders / notifications
|
||||
|
||||
Calendarr requires access to a Calendarr server (self-hosted). Enter your server URL, then sign in.
|
||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Calendar client for your self-hosted Calendarr server
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Calendarr
|
||||
30
fdroid/com.scarriffle.calendarr.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
# F-Droid build recipe for the OFFICIAL repository.
|
||||
# Submit this file to https://gitlab.com/fdroid/fdroiddata as
|
||||
# metadata/com.scarriffle.calendarr.yml
|
||||
# (see FDROID.md). F-Droid clones the source below, builds it on their servers
|
||||
# and signs it with F-Droid's key. Requires the SourceCode repo to be publicly
|
||||
# clonable and a git tag `v<versionName>` (e.g. v1.1.0) for each release.
|
||||
|
||||
Categories:
|
||||
- Time
|
||||
License: GPL-3.0-or-later
|
||||
AuthorName: Scarriffle
|
||||
WebSite: https://calendar.scarriffle.com
|
||||
SourceCode: https://git.scarriffle.com/Scarriffle/Calendarr-Android
|
||||
IssueTracker: https://git.scarriffle.com/Scarriffle/Calendarr-Android/issues
|
||||
|
||||
RepoType: git
|
||||
Repo: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git
|
||||
|
||||
Builds:
|
||||
- versionName: 1.1.0
|
||||
versionCode: 2
|
||||
commit: v1.1.0
|
||||
subdir: app
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version v%v
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 1.1.0
|
||||
CurrentVersionCode: 2
|
||||
9
keystore.properties.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy this file to "keystore.properties" (git-ignored) and fill in your real
|
||||
# values to enable a signed release build: ./gradlew bundleRelease
|
||||
#
|
||||
# storeFile: ABSOLUTE path to your upload keystore (.jks). Keep the keystore and
|
||||
# this filled-in file OUT of version control (both are git-ignored).
|
||||
storeFile=/absolute/path/to/upload-keystore.jks
|
||||
storePassword=CHANGE_ME
|
||||
keyAlias=upload
|
||||
keyPassword=CHANGE_ME
|
||||