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
|
# Keystore files
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
keystore.properties
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.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 {
|
plugins {
|
||||||
id("com.android.application") version "8.10.0"
|
id("com.android.application") version "8.10.0"
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.20"
|
id("org.jetbrains.kotlin.android") version "1.9.20"
|
||||||
@@ -5,25 +8,47 @@ plugins {
|
|||||||
id("com.google.dagger.hilt.android") version "2.49"
|
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 {
|
android {
|
||||||
namespace = "com.scarriffle.calendarr"
|
namespace = "com.scarriffle.calendarr"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.scarriffle.calendarr"
|
applicationId = "com.scarriffle.calendarr"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "1.1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables { useSupportLibrary = true }
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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
|
<application
|
||||||
android:name=".CalendarrApplication"
|
android:name=".CalendarrApplication"
|
||||||
@@ -21,5 +23,9 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notifications.ReminderReceiver"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.scarriffle.calendarr
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.scarriffle.calendarr.ui.CalendarrRoot
|
import com.scarriffle.calendarr.ui.CalendarrRoot
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Covers the window from the first frame (incl. warm-start), then hands
|
// 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.
|
// off to the in-app branded splash which stays until data is loaded.
|
||||||
installSplashScreen()
|
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)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
CalendarrRoot()
|
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)
|
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],
|
* 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`
|
* 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 getSettings(): AppSettings = guarded { api.getSettings() }
|
||||||
|
|
||||||
suspend fun updateSettings(s: AppSettings) = guarded {
|
/** Push only the values whose sync flag is on (partial update; the server
|
||||||
api.updateSettings(
|
* leaves unsynced columns untouched), plus the account-wide flag map. */
|
||||||
jsonBody(
|
suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
|
||||||
"default_view" to s.defaultView,
|
val body = mutableMapOf<String, Any?>()
|
||||||
"week_start_day" to s.weekStartDay,
|
fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
|
||||||
"primary_color" to s.primaryColor,
|
addIf("default_view", s.defaultView)
|
||||||
"accent_color" to s.accentColor,
|
addIf("week_start_day", s.weekStartDay)
|
||||||
"today_color" to s.todayColor,
|
addIf("dim_past_events", s.dimPastEvents)
|
||||||
"dim_past_events" to s.dimPastEvents,
|
addIf("hour_height", s.hourHeight)
|
||||||
"text_contrast" to s.textContrast,
|
addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
|
||||||
"line_contrast" to s.lineContrast,
|
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
|
||||||
"hour_height" to s.hourHeight,
|
addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
|
||||||
"language" to s.language,
|
addIf("primary_color", s.primaryColor)
|
||||||
"month_divider_color" to s.monthDividerColor,
|
addIf("accent_color", s.accentColor)
|
||||||
"month_label_color" to s.monthLabelColor,
|
addIf("today_color", s.todayColor)
|
||||||
"private_event_visibility" to s.privateEventVisibility,
|
addIf("text_color", s.textColor)
|
||||||
)
|
addIf("bg_color", s.backgroundColor)
|
||||||
).ensureSuccess()
|
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() }
|
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 getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
|
||||||
|
|
||||||
suspend fun addLocalCalendar(name: String, color: String) =
|
suspend fun addLocalCalendar(
|
||||||
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) }
|
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() }
|
suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() }
|
||||||
|
|
||||||
@@ -212,9 +235,11 @@ class CalendarRepository @Inject constructor(
|
|||||||
suspend fun deleteHomeAssistantAccount(id: Int) =
|
suspend fun deleteHomeAssistantAccount(id: Int) =
|
||||||
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
|
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 {
|
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. */
|
/** 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. */
|
/** Resolve all calendars the user can create events in. */
|
||||||
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
|
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
|
||||||
val result = mutableListOf<WritableCalendar>()
|
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)
|
result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
|
||||||
}
|
}
|
||||||
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())
|
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())
|
||||||
@@ -281,35 +324,50 @@ class CalendarRepository @Inject constructor(
|
|||||||
|
|
||||||
// ---- Events ----
|
// ---- 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))
|
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
|
||||||
resp.ensureSuccess()
|
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 root = JSONObject(raw)
|
||||||
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
|
val arr = root.optJSONArray("events")
|
||||||
buildList {
|
val events = buildList {
|
||||||
for (i in 0 until arr.length()) {
|
if (arr != null) for (i in 0 until arr.length()) {
|
||||||
val obj = arr.optJSONObject(i) ?: continue
|
val obj = arr.optJSONObject(i) ?: continue
|
||||||
CalEvent.fromJson(obj)?.let { add(it) }
|
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(
|
suspend fun createLocalEvent(
|
||||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
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 {
|
) = 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()
|
.ensureSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateLocalEvent(
|
suspend fun updateLocalEvent(
|
||||||
uid: String, title: String, start: Instant, end: Instant,
|
uid: String, title: String, start: Instant, end: Instant,
|
||||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
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 {
|
) = 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()
|
.ensureSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,8 +504,13 @@ class CalendarRepository @Inject constructor(
|
|||||||
|
|
||||||
// ---- Profile & targeted settings ----
|
// ---- Profile & targeted settings ----
|
||||||
|
|
||||||
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded {
|
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))
|
val resp = api.updateProfile(jsonBody(
|
||||||
|
"display_name" to displayName,
|
||||||
|
"username" to username,
|
||||||
|
"email" to email,
|
||||||
|
"directory_hidden" to directoryHidden,
|
||||||
|
))
|
||||||
resp.ensureSuccess()
|
resp.ensureSuccess()
|
||||||
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
|
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -555,7 +618,8 @@ class CalendarRepository @Inject constructor(
|
|||||||
private fun eventBody(
|
private fun eventBody(
|
||||||
calendarId: Int?, title: String, start: Instant, end: Instant,
|
calendarId: Int?, title: String, start: Instant, end: Instant,
|
||||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
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(
|
) = jsonBody(
|
||||||
buildMap {
|
buildMap {
|
||||||
calendarId?.let { put("calendar_id", it) }
|
calendarId?.let { put("calendar_id", it) }
|
||||||
@@ -567,6 +631,20 @@ class CalendarRepository @Inject constructor(
|
|||||||
put("description", description)
|
put("description", description)
|
||||||
if (!color.isNullOrBlank()) put("color", color)
|
if (!color.isNullOrBlank()) put("color", color)
|
||||||
put("private", isPrivate)
|
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",
|
language = prefs.getString(K_LANGUAGE, null) ?: "de",
|
||||||
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
|
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
|
||||||
monthLabelColor = prefs.getString(K_LABEL, 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) {
|
fun saveSettings(s: AppSettings) {
|
||||||
@@ -50,14 +57,127 @@ class SettingsStore @Inject constructor(
|
|||||||
.putString(K_LANGUAGE, s.language)
|
.putString(K_LANGUAGE, s.language)
|
||||||
.putString(K_DIVIDER, s.monthDividerColor)
|
.putString(K_DIVIDER, s.monthDividerColor)
|
||||||
.putString(K_LABEL, s.monthLabelColor)
|
.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()
|
.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). */
|
/** Device-local cache range in months around today (default 3). */
|
||||||
var cacheMonths: Int
|
var cacheMonths: Int
|
||||||
get() = prefs.getInt(K_CACHE_MONTHS, 3)
|
get() = prefs.getInt(K_CACHE_MONTHS, 3)
|
||||||
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply()
|
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") ---
|
// --- Hidden calendars ("source:id") ---
|
||||||
|
|
||||||
var hiddenCalendarKeys: Set<String>
|
var hiddenCalendarKeys: Set<String>
|
||||||
@@ -70,6 +190,27 @@ class SettingsStore @Inject constructor(
|
|||||||
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
|
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
|
||||||
set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply()
|
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 {
|
private companion object {
|
||||||
const val K_DEFAULT_VIEW = "default_view"
|
const val K_DEFAULT_VIEW = "default_view"
|
||||||
const val K_WEEK_START = "week_start_day"
|
const val K_WEEK_START = "week_start_day"
|
||||||
@@ -83,8 +224,20 @@ class SettingsStore @Inject constructor(
|
|||||||
const val K_LANGUAGE = "language"
|
const val K_LANGUAGE = "language"
|
||||||
const val K_DIVIDER = "month_divider_color"
|
const val K_DIVIDER = "month_divider_color"
|
||||||
const val K_LABEL = "month_label_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_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_HIDDEN = "hidden_calendar_keys"
|
||||||
const val K_BANISHED = "banished_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}")
|
@PUT("api/local/calendars/{id}")
|
||||||
suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
|
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}")
|
@DELETE("api/local/calendars/{id}")
|
||||||
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
|
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
|
||||||
|
|
||||||
@@ -217,6 +222,12 @@ interface CalendarrApi {
|
|||||||
@DELETE("api/local/events/{uid}")
|
@DELETE("api/local/events/{uid}")
|
||||||
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
|
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")
|
@POST("api/caldav/events")
|
||||||
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
|
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ data class CalDAVCalendar(
|
|||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
||||||
|
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
@@ -34,6 +35,9 @@ data class LocalCalendar(
|
|||||||
@Json(name = "shared_by") val sharedBy: String? = null,
|
@Json(name = "shared_by") val sharedBy: String? = null,
|
||||||
val permission: String? = null,
|
val permission: String? = null,
|
||||||
val group: Boolean = false,
|
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)
|
@JsonClass(generateAdapter = false)
|
||||||
@@ -45,6 +49,7 @@ data class ICalSubscription(
|
|||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
@Json(name = "refresh_minutes") val refreshMinutes: Int = 60,
|
@Json(name = "refresh_minutes") val refreshMinutes: Int = 60,
|
||||||
@Json(name = "last_fetched") val lastFetched: String? = null,
|
@Json(name = "last_fetched") val lastFetched: String? = null,
|
||||||
|
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
@@ -61,6 +66,7 @@ data class GoogleCalendar(
|
|||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
||||||
|
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
@@ -80,6 +86,7 @@ data class HACalendar(
|
|||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
|
||||||
|
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
@@ -91,6 +98,19 @@ data class UserProfile(
|
|||||||
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
||||||
@Json(name = "has_avatar") val hasAvatar: Boolean = false,
|
@Json(name = "has_avatar") val hasAvatar: Boolean = false,
|
||||||
@Json(name = "totp_enabled") val totpEnabled: 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). */
|
/** 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 = "language") val language: String = "de",
|
||||||
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
|
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
|
||||||
@Json(name = "month_label_color") val monthLabelColor: 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'.
|
// How this user's private events appear to other group members: 'hidden' | 'busy'.
|
||||||
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
|
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
|
||||||
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
|
@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"
|
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,23 @@ data class CalEvent(
|
|||||||
val owner: EventPerson? = null,
|
val owner: EventPerson? = null,
|
||||||
val isGroupEvent: Boolean = false,
|
val isGroupEvent: Boolean = false,
|
||||||
val displayColor: String? = null,
|
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
|
* Group view supplies a server-resolved colour (display_color); otherwise
|
||||||
* per-event override colour, then the calendar's colour, then a stable
|
* per-event override colour, then the calendar's colour, then a stable
|
||||||
@@ -117,6 +133,12 @@ data class CalEvent(
|
|||||||
owner = personFrom(json, "owner"),
|
owner = personFrom(json, "owner"),
|
||||||
isGroupEvent = json.optBoolean("is_group_event", false),
|
isGroupEvent = json.optBoolean("is_group_event", false),
|
||||||
displayColor = json.strOrNull("display_color"),
|
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
|
package com.scarriffle.calendarr.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -48,19 +50,29 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (route) {
|
when (route) {
|
||||||
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
|
// Auth screens draw full-screen; inset them from the system
|
||||||
AppRoute.LOGIN -> LoginScreen(
|
// 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,
|
serverUrl = vm.serverUrl,
|
||||||
onLoggedIn = vm::onLoggedIn,
|
onLoggedIn = vm::onLoggedIn,
|
||||||
onBack = vm::switchServer,
|
onBack = vm::switchServer,
|
||||||
)
|
)
|
||||||
AppRoute.MAIN -> CalendarScreen(
|
}
|
||||||
vm = calendarVm!!,
|
AppRoute.MAIN -> calendarVm?.let { cvm ->
|
||||||
|
CalendarScreen(
|
||||||
|
vm = cvm,
|
||||||
onLogout = vm::logout,
|
onLogout = vm::logout,
|
||||||
onSwitchServer = vm::switchServer,
|
onSwitchServer = vm::switchServer,
|
||||||
onSettingsChanged = vm::applyLocalSettings,
|
onSettingsChanged = vm::applyLocalSettings,
|
||||||
onSettingsSynced = vm::refreshSettings,
|
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_title" to "Keine Termine",
|
||||||
"cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.",
|
"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.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.section.settings" to "Einstellungen", "menu.profile" to "Profil",
|
||||||
"menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender",
|
"menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender",
|
||||||
"menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin",
|
"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.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
|
||||||
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
|
"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.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.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
|
||||||
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
|
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
|
||||||
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
|
"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.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
|
||||||
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
|
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
|
||||||
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
|
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
|
||||||
|
"settings.month_paged" to "Monatsansicht seitenweise wischen",
|
||||||
"settings.hourheight" to "Stundenhöhe",
|
"settings.hourheight" to "Stundenhöhe",
|
||||||
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
|
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
|
||||||
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross",
|
"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.detail_title" to "Termin", "event.source" to "Quelle",
|
||||||
"event.save" to "Sichern", "event.add" to "Hinzufügen",
|
"event.save" to "Sichern", "event.add" to "Hinzufügen",
|
||||||
"event.delete_confirm" to "Diesen Termin löschen?",
|
"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.title" to "Konten", "accounts.loading" to "Lade Konten…",
|
||||||
"accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-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",
|
"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",
|
"accounts.ha.add" to "Home Assistant hinzufügen",
|
||||||
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
|
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
|
||||||
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
|
"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.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.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
|
||||||
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
|
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
|
||||||
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
|
"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.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
|
||||||
"local.create" to "Erstellen",
|
"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.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.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.",
|
"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_title" to "No events",
|
||||||
"cal.no_events_body" to "No events in the next 90 days.",
|
"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.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.section.settings" to "Settings", "menu.profile" to "Profile",
|
||||||
"menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars",
|
"menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars",
|
||||||
"menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin",
|
"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.colors" to "Colors", "settings.color.primary" to "Primary color",
|
||||||
"settings.color.accent" to "Accent color", "settings.color.today" to "Today 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.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.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
|
||||||
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
|
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
|
||||||
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
|
"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.calview" to "Calendar view", "settings.defaultview" to "Default view",
|
||||||
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
|
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
|
||||||
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events",
|
"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" to "Hour height",
|
||||||
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
|
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
|
||||||
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",
|
"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.detail_title" to "Event", "event.source" to "Source",
|
||||||
"event.save" to "Save", "event.add" to "Add",
|
"event.save" to "Save", "event.add" to "Add",
|
||||||
"event.delete_confirm" to "Delete this event?",
|
"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.title" to "Accounts", "accounts.loading" to "Loading accounts…",
|
||||||
"accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV 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",
|
"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",
|
"accounts.ha.add" to "Add Home Assistant",
|
||||||
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
|
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
|
||||||
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
|
"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.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.display_name" to "Display name", "caldav.url" to "CalDAV URL",
|
||||||
"caldav.username" to "Username", "caldav.password" to "Password",
|
"caldav.username" to "Username", "caldav.password" to "Password",
|
||||||
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",
|
"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.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
|
||||||
"local.create" to "Create",
|
"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.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.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",
|
"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()
|
_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() {
|
fun refreshSettings() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.getSettings() }.onSuccess { s ->
|
runCatching { repository.getSettings() }.onSuccess { s ->
|
||||||
settingsStore.saveSettings(s)
|
_settings.value = settingsStore.applyServerPull(s)
|
||||||
_settings.value = s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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) {
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
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)) } }
|
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
|
||||||
item { Spacer(Modifier.size(40.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. */
|
/** A simple row (iCal) with editable colour + delete. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {
|
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {
|
||||||
@@ -332,7 +381,9 @@ private fun LocalCalendarRow(
|
|||||||
) {
|
) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
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)) {
|
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(cal.name, style = MaterialTheme.typography.bodyLarge)
|
Text(cal.name, style = MaterialTheme.typography.bodyLarge)
|
||||||
@@ -380,7 +431,7 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
|
|||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
Column(
|
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)
|
Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|||||||
@@ -61,8 +61,12 @@ class AccountsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addLocal(name: String, color: String, onChanged: () -> Unit) =
|
fun addLocal(
|
||||||
mutate(onChanged) { repository.addLocalCalendar(name, color) }
|
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) =
|
fun deleteLocal(id: Int, onChanged: () -> Unit) =
|
||||||
mutate(onChanged) { repository.deleteLocalCalendar(id) }
|
mutate(onChanged) { repository.deleteLocalCalendar(id) }
|
||||||
@@ -99,6 +103,13 @@ class AccountsViewModel @Inject constructor(
|
|||||||
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
|
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
|
||||||
mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) }
|
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 ----
|
// ---- Sharing ----
|
||||||
|
|
||||||
var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList())
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
@@ -102,7 +103,7 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (vm.loggingIn) {
|
if (vm.loggingIn) {
|
||||||
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Text(tr("auth.login"))
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
@@ -78,7 +79,7 @@ fun ServerSetupScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (vm.checking) {
|
if (vm.checking) {
|
||||||
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
} else {
|
} else {
|
||||||
Text(tr("auth.continue"))
|
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)))
|
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
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()) {
|
if (event.location.isNotBlank()) {
|
||||||
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.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.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Switch
|
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.ui.tr
|
||||||
import com.scarriffle.calendarr.util.colorFromHex
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -36,9 +47,28 @@ fun CalendarFilterSheet(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state by vm.state.collectAsState()
|
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) {
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -46,36 +76,91 @@ fun CalendarFilterSheet(
|
|||||||
) {
|
) {
|
||||||
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
Row {
|
Row {
|
||||||
TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) }
|
|
||||||
TextButton(onClick = {
|
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")) }
|
}) { Text(tr("filter.hide_all")) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (events.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
tr("filter.empty"),
|
tr("filter.empty"),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(vertical = 24.dp),
|
modifier = Modifier.padding(vertical = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
events.forEach { entry ->
|
rows.forEach { entry ->
|
||||||
val visible = entry.key !in state.hiddenKeys
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
|
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(
|
Text(
|
||||||
entry.name,
|
entry.name,
|
||||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
Switch(
|
if (entry.readOnly) {
|
||||||
checked = visible,
|
Icon(
|
||||||
onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) },
|
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))
|
Box(Modifier.padding(bottom = 24.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,37 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
import androidx.compose.material.icons.filled.ChevronLeft
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
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.Menu
|
||||||
import androidx.compose.material.icons.filled.Today
|
import androidx.compose.material.icons.filled.Today
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
@@ -35,15 +45,18 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.domain.model.CalViewType
|
||||||
import com.scarriffle.calendarr.ui.LocalLang
|
import com.scarriffle.calendarr.ui.LocalLang
|
||||||
import com.scarriffle.calendarr.ui.accounts.AccountsScreen
|
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.EventDetailScreen
|
||||||
import com.scarriffle.calendarr.ui.event.EventEditorSheet
|
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.profile.ProfileScreen
|
||||||
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
||||||
import com.scarriffle.calendarr.ui.tr
|
import com.scarriffle.calendarr.ui.tr
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDate
|
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)
|
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarScreen(
|
fun CalendarScreen(
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onSwitchServer: () -> Unit,
|
onSwitchServer: () -> Unit,
|
||||||
onSettingsChanged: (AppSettings) -> Unit,
|
onSettingsChanged: (AppSettings) -> Unit,
|
||||||
onSettingsSynced: () -> Unit,
|
onSettingsSynced: () -> Unit,
|
||||||
|
username: String = "",
|
||||||
|
serverUrl: String = "",
|
||||||
vm: CalendarViewModel = hiltViewModel(),
|
vm: CalendarViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
val lang = LocalLang.current
|
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 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 detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
||||||
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
||||||
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
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
|
// Continuous month scrolling
|
||||||
val monthListState = rememberLazyListState()
|
val monthListState = rememberLazyListState()
|
||||||
@@ -106,6 +193,18 @@ fun CalendarScreen(
|
|||||||
if (isMonth) todaySignal++ else vm.moveToToday()
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
CompactTopBar(
|
CompactTopBar(
|
||||||
@@ -113,30 +212,67 @@ fun CalendarScreen(
|
|||||||
viewType = state.viewType,
|
viewType = state.viewType,
|
||||||
loading = state.isLoading || state.isBackgroundCaching,
|
loading = state.isLoading || state.isBackgroundCaching,
|
||||||
viewMenuOpen = viewMenuOpen,
|
viewMenuOpen = viewMenuOpen,
|
||||||
onMenu = { showMenu = true },
|
showMenuButton = !state.hideMenuButton,
|
||||||
|
onMenu = { drawerScope.launch { drawerState.open() } },
|
||||||
onPrev = { goPrev() },
|
onPrev = { goPrev() },
|
||||||
onToday = { goToday() },
|
onToday = { goToday() },
|
||||||
onNext = { goNext() },
|
onNext = { goNext() },
|
||||||
onFilter = { showFilter = true },
|
|
||||||
onViewMenuToggle = { viewMenuOpen = it },
|
onViewMenuToggle = { viewMenuOpen = it },
|
||||||
onSelectView = { vm.setViewType(it) },
|
onSelectView = { vm.setViewType(it) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
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(
|
FloatingActionButton(
|
||||||
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
|
onClick = {},
|
||||||
|
shape = CircleShape,
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
|
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 ->
|
) { padding ->
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) {
|
||||||
state.error?.let { err ->
|
state.error?.let { err ->
|
||||||
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
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()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
// remember: stable callbacks keep the lazily composed week rows
|
||||||
|
// skippable during scroll (fresh lambdas would recompose them all).
|
||||||
CalendarBody(
|
CalendarBody(
|
||||||
state = state,
|
state = state,
|
||||||
vm = vm,
|
vm = vm,
|
||||||
@@ -144,35 +280,41 @@ fun CalendarScreen(
|
|||||||
scrollToTodaySignal = todaySignal,
|
scrollToTodaySignal = todaySignal,
|
||||||
monthJumpSignal = monthJumpSignal,
|
monthJumpSignal = monthJumpSignal,
|
||||||
monthJumpTarget = monthJumpTarget,
|
monthJumpTarget = monthJumpTarget,
|
||||||
onVisibleMonthChange = { visibleMonth = it },
|
onVisibleMonthChange = remember { { visibleMonth = it } },
|
||||||
onEventClick = { detailEvent = it },
|
onEventClick = remember { { detailEvent = it } },
|
||||||
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
|
onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } },
|
||||||
onDayLongPress = { date -> editor = EditorRequest(null, date) },
|
onDayLongPress = remember { { date -> dayPreview = date } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Sheets ----
|
// ---- Sheets ----
|
||||||
|
|
||||||
if (showMenu) {
|
dayPreview?.let { date ->
|
||||||
MenuSheet(
|
DayPreviewDialog(
|
||||||
isAdmin = false,
|
date = date,
|
||||||
onDismiss = { showMenu = false },
|
events = remember(state.events, date) { vm.eventsOn(date, state.events) },
|
||||||
onProfile = { showMenu = false; overlay = Overlay.PROFILE },
|
onDismiss = { dayPreview = null },
|
||||||
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
|
onEventClick = { ev -> dayPreview = null; detailEvent = ev },
|
||||||
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
|
onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) },
|
||||||
onSync = { showMenu = false; vm.syncWithServer() },
|
onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) },
|
||||||
onLogout = { showMenu = false; onLogout() },
|
onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) },
|
||||||
onSwitchServer = { showMenu = false; onSwitchServer() },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showFilter) {
|
if (showBirthday) {
|
||||||
CalendarFilterSheet(
|
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
|
||||||
events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) },
|
BirthdayDialog(
|
||||||
vm = vm,
|
calendar = birthdayCal,
|
||||||
onDismiss = { showFilter = false },
|
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,
|
request = req,
|
||||||
writableCalendars = state.writableCalendars,
|
writableCalendars = state.writableCalendars,
|
||||||
onDismiss = { editor = null },
|
onDismiss = { editor = null },
|
||||||
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
|
defaultDurationMinutes = vm.defaultEventDurationMinutes,
|
||||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
|
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
|
if (error == null) editor = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -220,16 +364,33 @@ fun CalendarScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when (overlay) {
|
when (overlay) {
|
||||||
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE })
|
Overlay.MENU -> MenuScreen(
|
||||||
Overlay.SETTINGS -> SettingsScreen(
|
username = username,
|
||||||
|
serverUrl = serverUrl,
|
||||||
onClose = { overlay = Overlay.NONE },
|
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,
|
onSettingsChanged = onSettingsChanged,
|
||||||
onSettingsSynced = onSettingsSynced,
|
onSettingsSynced = onSettingsSynced,
|
||||||
)
|
)
|
||||||
Overlay.ACCOUNTS -> AccountsScreen(
|
Overlay.ACCOUNTS -> AccountsScreen(
|
||||||
onClose = { overlay = Overlay.NONE },
|
onClose = { overlay = Overlay.MENU },
|
||||||
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
|
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
|
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
|
@Composable
|
||||||
private fun loadingPlaceholder() {
|
private fun loadingPlaceholder() {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -296,20 +484,24 @@ private fun CompactTopBar(
|
|||||||
viewType: CalViewType,
|
viewType: CalViewType,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
viewMenuOpen: Boolean,
|
viewMenuOpen: Boolean,
|
||||||
|
showMenuButton: Boolean,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPrev: () -> Unit,
|
onPrev: () -> Unit,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onFilter: () -> Unit,
|
|
||||||
onViewMenuToggle: (Boolean) -> Unit,
|
onViewMenuToggle: (Boolean) -> Unit,
|
||||||
onSelectView: (CalViewType) -> Unit,
|
onSelectView: (CalViewType) -> Unit,
|
||||||
) {
|
) {
|
||||||
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY
|
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) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(Modifier.statusBarsPadding()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
|
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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)
|
CompactIcon(Icons.Filled.ChevronLeft, onPrev)
|
||||||
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
|
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
|
||||||
Text(tr("nav.today"), fontSize = 13.sp)
|
Text(tr("nav.today"), fontSize = 13.sp)
|
||||||
@@ -331,7 +523,6 @@ private fun CompactTopBar(
|
|||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
|
|
||||||
Box {
|
Box {
|
||||||
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
|
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
|
||||||
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
|
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> {
|
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
|
||||||
val st = vm.state.value
|
val fromEvents = vm.knownCalendars()
|
||||||
return st.events
|
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) }
|
||||||
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor) }
|
// 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 }
|
.distinctBy { it.key }
|
||||||
.sortedBy { it.name.lowercase() }
|
.sortedBy { it.name.lowercase() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.scarriffle.calendarr.data.CalendarRepository
|
import com.scarriffle.calendarr.data.CalendarRepository
|
||||||
import com.scarriffle.calendarr.data.SettingsStore
|
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.CalEvent
|
||||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
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.domain.model.WritableCalendar
|
||||||
|
import com.scarriffle.calendarr.util.Dates
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -34,12 +39,36 @@ data class CalendarUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isBackgroundCaching: Boolean = false,
|
val isBackgroundCaching: Boolean = false,
|
||||||
val error: String? = null,
|
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 weekStartsOnMonday: Boolean = true,
|
||||||
val writableCalendars: List<WritableCalendar> = emptyList(),
|
val writableCalendars: List<WritableCalendar> = emptyList(),
|
||||||
val hiddenKeys: Set<String> = emptySet(),
|
val hiddenKeys: Set<String> = emptySet(),
|
||||||
val banishedKeys: 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
|
@HiltViewModel
|
||||||
class CalendarViewModel @Inject constructor(
|
class CalendarViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
@@ -68,7 +97,8 @@ class CalendarViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadWritableCalendars()
|
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
|
* mark ready. Entering the app fully-loaded avoids the first-open jank of
|
||||||
* loading a big batch while the user is already scrolling.
|
* 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 months = settingsStore.cacheMonths.toLong()
|
||||||
val today = LocalDate.now().withDayOfMonth(1)
|
val today = LocalDate.now().withDayOfMonth(1)
|
||||||
val start = instant(today.minusMonths(months))
|
val start = instant(today.minusMonths(months))
|
||||||
val end = instant(today.plusMonths(months + 1))
|
val end = instant(today.plusMonths(months + 1))
|
||||||
viewModelScope.launch {
|
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)
|
loadRange(start, end, background = false)
|
||||||
markReady()
|
markReady()
|
||||||
}
|
}
|
||||||
@@ -94,9 +127,100 @@ class CalendarViewModel @Inject constructor(
|
|||||||
weekStartsOnMonday = s.weekStartsOnMonday,
|
weekStartsOnMonday = s.weekStartsOnMonday,
|
||||||
hiddenKeys = settingsStore.hiddenCalendarKeys,
|
hiddenKeys = settingsStore.hiddenCalendarKeys,
|
||||||
banishedKeys = settingsStore.banishedCalendarKeys,
|
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 ----
|
// ---- Navigation ----
|
||||||
|
|
||||||
fun setViewType(type: CalViewType) {
|
fun setViewType(type: CalViewType) {
|
||||||
@@ -174,7 +298,7 @@ class CalendarViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
loadRange(start, end, background = false)
|
loadRange(start, end, background = false, force = force)
|
||||||
markReady()
|
markReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,19 +313,49 @@ class CalendarViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
|
/** 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 {
|
loadMutex.withLock {
|
||||||
// Another load (e.g. the background prefetch) may have covered this range.
|
// 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"
|
val flag = if (background) "bg" else "fg"
|
||||||
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
||||||
runCatching { repository.fetchEvents(start, end) }
|
runCatching {
|
||||||
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
|
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) } }
|
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
|
||||||
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
|
_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() {
|
private fun markReady() {
|
||||||
_ready.value = true
|
_ready.value = true
|
||||||
}
|
}
|
||||||
@@ -212,22 +366,76 @@ class CalendarViewModel @Inject constructor(
|
|||||||
return !cs.isAfter(start) && !ce.isBefore(end)
|
return !cs.isAfter(start) && !ce.isBefore(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeIntoCache(newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant) {
|
/**
|
||||||
val retained = allCachedEvents.filter {
|
* Split sync errors into per-calendar keys and whole sources whose cached
|
||||||
!it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart)
|
* 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
|
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
|
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
|
||||||
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
|
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshFromCache() {
|
private fun refreshFromCache() {
|
||||||
val hidden = _state.value.hiddenKeys
|
val st = _state.value
|
||||||
val banished = _state.value.banishedKeys
|
// In group mode: server scopes/filters by privacy; locally honour the
|
||||||
val visible = allCachedEvents.filter { ev ->
|
// 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)
|
val key = calendarKey(ev.source, ev.calendarId)
|
||||||
key !in hidden && key !in banished
|
key !in hidden && key !in banished
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Skip the state write (and resulting recomposition) when nothing changed.
|
// Skip the state write (and resulting recomposition) when nothing changed.
|
||||||
_state.update { if (it.events == visible) it else it.copy(events = visible) }
|
_state.update { if (it.events == visible) it else it.copy(events = visible) }
|
||||||
}
|
}
|
||||||
@@ -240,11 +448,61 @@ class CalendarViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun syncWithServer() {
|
fun syncWithServer() {
|
||||||
invalidateCache()
|
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 clearError() = _state.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
|
||||||
|
|
||||||
// ---- Visibility filters ----
|
// ---- Visibility filters ----
|
||||||
|
|
||||||
fun setCalendarHidden(key: String, hidden: Boolean) {
|
fun setCalendarHidden(key: String, hidden: Boolean) {
|
||||||
@@ -262,6 +520,98 @@ class CalendarViewModel @Inject constructor(
|
|||||||
refreshFromCache()
|
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) {
|
fun setCalendarBanished(key: String, banished: Boolean) {
|
||||||
val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
|
val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
|
||||||
if (banished) add(key) else remove(key)
|
if (banished) add(key) else remove(key)
|
||||||
@@ -273,6 +623,70 @@ class CalendarViewModel @Inject constructor(
|
|||||||
settingsStore.hiddenCalendarKeys = nextHidden
|
settingsStore.hiddenCalendarKeys = nextHidden
|
||||||
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
|
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
|
||||||
refreshFromCache()
|
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 ----
|
// ---- Writable calendars ----
|
||||||
@@ -286,9 +700,23 @@ class CalendarViewModel @Inject constructor(
|
|||||||
|
|
||||||
// ---- Event mutations ----
|
// ---- 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() {
|
private fun afterMutation() {
|
||||||
invalidateCache()
|
val start = cachedStart
|
||||||
|
val end = cachedEnd
|
||||||
|
if (start == null || end == null) {
|
||||||
initialLoad()
|
initialLoad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadRange(start, end, background = false, force = true)
|
||||||
|
markReady()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveEvent(
|
fun saveEvent(
|
||||||
@@ -302,20 +730,21 @@ class CalendarViewModel @Inject constructor(
|
|||||||
description: String,
|
description: String,
|
||||||
color: String?,
|
color: String?,
|
||||||
isPrivate: Boolean,
|
isPrivate: Boolean,
|
||||||
|
reminders: List<Int> = emptyList(),
|
||||||
onResult: (String?) -> Unit,
|
onResult: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
if (existing != null && existing.source == calendar.source) {
|
if (existing != null && existing.source == calendar.source) {
|
||||||
when (existing.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)
|
"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)
|
"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)
|
"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 {
|
} 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) }
|
result.onSuccess { afterMutation(); onResult(null) }
|
||||||
@@ -326,9 +755,10 @@ class CalendarViewModel @Inject constructor(
|
|||||||
private suspend fun createForSource(
|
private suspend fun createForSource(
|
||||||
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
||||||
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
||||||
|
reminders: List<Int> = emptyList(),
|
||||||
) {
|
) {
|
||||||
when (calendar.source) {
|
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)
|
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||||
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||||
"homeassistant" -> repository.createHAEvent(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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -62,7 +64,8 @@ private const val MAX_LANES = 4
|
|||||||
private val DAY_NUM_H = 22.dp
|
private val DAY_NUM_H = 22.dp
|
||||||
private val LANE_H = 15.dp
|
private val LANE_H = 15.dp
|
||||||
private val LANE_SPACE = 2.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 }
|
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)
|
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
|
@Composable
|
||||||
fun MonthView(
|
fun MonthView(
|
||||||
state: CalendarUiState,
|
state: CalendarUiState,
|
||||||
@@ -97,11 +103,14 @@ fun MonthView(
|
|||||||
val mondayFirst = state.weekStartsOnMonday
|
val mondayFirst = state.weekStartsOnMonday
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
|
|
||||||
val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0))
|
val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
|
val outline = MaterialTheme.colorScheme.outline
|
||||||
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast))
|
val onBackground = MaterialTheme.colorScheme.onBackground
|
||||||
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
|
val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) }
|
||||||
val todayColor = colorFromHex(settings.todayColor)
|
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).
|
// Column width measured once → no per-row BoxWithConstraints (smooth scrolling).
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -123,6 +132,39 @@ fun MonthView(
|
|||||||
|
|
||||||
val todayIndex = remember(firstVisible) { weekIndexOf(today) }
|
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(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
|
||||||
LaunchedEffect(scrollToTodaySignal) {
|
LaunchedEffect(scrollToTodaySignal) {
|
||||||
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
|
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
|
||||||
@@ -141,10 +183,7 @@ fun MonthView(
|
|||||||
vm.ensureMonthLoaded(month)
|
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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
||||||
@@ -159,13 +198,19 @@ fun MonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
if (state.monthViewPaged) {
|
||||||
state = listState,
|
// One month per page, six height-filling week rows, swipe to change month.
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
|
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
|
||||||
) {
|
) { page ->
|
||||||
items(weekCount) { index ->
|
val month = firstMonth.plusMonths(page.toLong())
|
||||||
val weekStart = firstVisible.plusWeeks(index.toLong())
|
val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst)
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
repeat(6) { w ->
|
||||||
|
val weekStart = firstWeek.plusWeeks(w.toLong())
|
||||||
WeekRow(
|
WeekRow(
|
||||||
|
rowModifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
today = today,
|
today = today,
|
||||||
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
||||||
@@ -173,6 +218,7 @@ fun MonthView(
|
|||||||
dimPast = dimPast,
|
dimPast = dimPast,
|
||||||
now = now,
|
now = now,
|
||||||
lang = lang,
|
lang = lang,
|
||||||
|
cwLabel = cwLabel,
|
||||||
dividerColor = dividerColor,
|
dividerColor = dividerColor,
|
||||||
gridColor = gridColor,
|
gridColor = gridColor,
|
||||||
labelColor = labelColor,
|
labelColor = labelColor,
|
||||||
@@ -185,10 +231,41 @@ fun MonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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,
|
||||||
|
secondaryText = secondaryText,
|
||||||
|
todayColor = todayColor,
|
||||||
|
onDayClick = onDayClick,
|
||||||
|
onDayLongPress = onDayLongPress,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekRow(
|
private fun WeekRow(
|
||||||
|
rowModifier: Modifier,
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
weekEvents: List<CalEvent>,
|
weekEvents: List<CalEvent>,
|
||||||
@@ -196,6 +273,7 @@ private fun WeekRow(
|
|||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: java.time.Instant,
|
now: java.time.Instant,
|
||||||
lang: String,
|
lang: String,
|
||||||
|
cwLabel: String,
|
||||||
dividerColor: Color,
|
dividerColor: Color,
|
||||||
gridColor: Color,
|
gridColor: Color,
|
||||||
labelColor: Color,
|
labelColor: Color,
|
||||||
@@ -208,11 +286,12 @@ private fun WeekRow(
|
|||||||
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
|
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
|
||||||
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
|
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
|
||||||
val rowStartsNewMonth = days[0].dayOfMonth == 1
|
val rowStartsNewMonth = days[0].dayOfMonth == 1
|
||||||
val cwLabel = tr("cal.cw")
|
|
||||||
|
|
||||||
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
|
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()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
days.forEachIndexed { idx, day ->
|
days.forEachIndexed { idx, day ->
|
||||||
val edge = when {
|
val edge = when {
|
||||||
@@ -275,13 +354,11 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () ->
|
|||||||
.padding(horizontal = 4.dp),
|
.padding(horizontal = 4.dp),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
EventLabel(
|
||||||
bar.event.title,
|
event = bar.event,
|
||||||
maxLines = 1,
|
color = bar.textColor,
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = bar.textColor,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.material3.Divider
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
@@ -38,6 +40,9 @@ import java.time.format.DateTimeFormatter
|
|||||||
|
|
||||||
private val GUTTER = 48.dp
|
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
|
@Composable
|
||||||
fun TimeGridView(
|
fun TimeGridView(
|
||||||
days: List<LocalDate>,
|
days: List<LocalDate>,
|
||||||
@@ -47,16 +52,21 @@ fun TimeGridView(
|
|||||||
) {
|
) {
|
||||||
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
|
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
|
||||||
val dimPast = LocalAppSettings.current.dimPastEvents
|
val dimPast = LocalAppSettings.current.dimPastEvents
|
||||||
val now = java.time.Instant.now()
|
|
||||||
val lang = LocalLang.current
|
|
||||||
val today = LocalDate.now()
|
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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
// Day headers (only for multi-day / week view)
|
// Day headers (only for multi-day / week view)
|
||||||
if (days.size > 1) {
|
if (days.size > 1) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
Box(Modifier.width(GUTTER))
|
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 ->
|
days.forEach { day ->
|
||||||
Text(
|
Text(
|
||||||
dayFmt.format(day),
|
dayFmt.format(day),
|
||||||
@@ -70,16 +80,48 @@ fun TimeGridView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All-day row
|
// All-day row: continuous bars spanning an event's start→end columns and
|
||||||
val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
// packed into lanes, instead of repeating a multi-day event once per day.
|
||||||
if (allDay.any { it.second.isNotEmpty() }) {
|
val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
||||||
Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
if (perDay.any { it.isNotEmpty() }) {
|
||||||
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
|
// event id -> [minCol, maxCol] within this week
|
||||||
Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
|
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)) {
|
// earlier / longer first, then greedily pack into lanes
|
||||||
evs.forEach { ev -> AllDayChip(ev, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) }
|
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),
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
EventLabel(
|
||||||
event.title,
|
event = event,
|
||||||
fontSize = 10.sp,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = color.contrastingTextColor(),
|
color = color.contrastingTextColor(),
|
||||||
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
if (height > 36.dp) {
|
if (height > 36.dp) {
|
||||||
Text(
|
Text(
|
||||||
@@ -206,7 +247,7 @@ private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
|
|||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 3.dp, vertical = 1.dp),
|
.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.Person
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -99,7 +98,7 @@ fun EventDetailScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
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()) {
|
if (event.calendarName.isNotBlank()) {
|
||||||
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -126,13 +125,15 @@ fun EventDetailScreen(
|
|||||||
Text(tr("event.copy_title"))
|
Text(tr("event.copy_title"))
|
||||||
}
|
}
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Button(
|
// Deliberately low-key (plain text button): destructive action
|
||||||
|
// should be findable, not the loudest element on the screen.
|
||||||
|
TextButton(
|
||||||
onClick = { confirmDelete = true },
|
onClick = { confirmDelete = true },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer),
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(tr("common.delete"))
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Button
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
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.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
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.domain.model.WritableCalendar
|
||||||
import com.scarriffle.calendarr.ui.L10n
|
import com.scarriffle.calendarr.ui.L10n
|
||||||
import com.scarriffle.calendarr.ui.LocalLang
|
import com.scarriffle.calendarr.ui.LocalLang
|
||||||
@@ -69,7 +77,9 @@ fun EventEditorSheet(
|
|||||||
request: EditorRequest,
|
request: EditorRequest,
|
||||||
writableCalendars: List<WritableCalendar>,
|
writableCalendars: List<WritableCalendar>,
|
||||||
onDismiss: () -> Unit,
|
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 zone = ZoneId.systemDefault()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -95,17 +105,20 @@ fun EventEditorSheet(
|
|||||||
var startTime by remember {
|
var startTime by remember {
|
||||||
mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0))
|
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 {
|
var endDate by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
initialEnd?.let {
|
initialEnd?.let {
|
||||||
val d = LocalDate.ofInstant(it, zone)
|
val d = LocalDate.ofInstant(it, zone)
|
||||||
if (template?.isAllDay == true) d.minusDays(1) else d
|
if (template?.isAllDay == true) d.minusDays(1) else d
|
||||||
} ?: request.date
|
} ?: defaultEnd.toLocalDate()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
var endTime by remember {
|
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 preselected = template?.let { ev ->
|
||||||
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
|
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
|
||||||
@@ -138,6 +151,8 @@ fun EventEditorSheet(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.imePadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
.padding(bottom = 32.dp),
|
.padding(bottom = 32.dp),
|
||||||
@@ -231,13 +246,42 @@ fun EventEditorSheet(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
|
|
||||||
// Private (local calendars only)
|
// Private + reminders (local calendars only)
|
||||||
if (calendar?.source == "local") {
|
if (calendar?.source == "local") {
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
|
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
|
||||||
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
|
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
|
||||||
}
|
}
|
||||||
Spacer(Modifier.size(12.dp))
|
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
|
// Color
|
||||||
@@ -280,7 +324,8 @@ fun EventEditorSheet(
|
|||||||
start = startDate.atTime(startTime).atZone(zone).toInstant()
|
start = startDate.atTime(startTime).atZone(zone).toInstant()
|
||||||
end = endDate.atTime(endTime).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(),
|
enabled = writableCalendars.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Close
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Divider
|
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.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -36,6 +37,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.colorFromHex
|
||||||
import com.scarriffle.calendarr.util.toHex
|
import com.scarriffle.calendarr.util.toHex
|
||||||
|
|
||||||
private val PALETTE = listOf(
|
// Canonical default colours (single source for the reset buttons).
|
||||||
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -70,7 +75,6 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val initialSettings = LocalAppSettings.current
|
val initialSettings = LocalAppSettings.current
|
||||||
var settings by remember { mutableStateOf(initialSettings) }
|
var settings by remember { mutableStateOf(initialSettings) }
|
||||||
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
|
|
||||||
|
|
||||||
fun update(newSettings: AppSettings) {
|
fun update(newSettings: AppSettings) {
|
||||||
settings = newSettings
|
settings = newSettings
|
||||||
@@ -78,6 +82,10 @@ fun SettingsScreen(
|
|||||||
vm.apply(newSettings, onSettingsSynced)
|
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) {
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -91,99 +99,254 @@ fun SettingsScreen(
|
|||||||
) { padding ->
|
) { padding ->
|
||||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
|
|
||||||
ProfileChapter(vm)
|
// Global "sync everything" master switch.
|
||||||
Divider(Modifier.padding(vertical = 16.dp))
|
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
Section(tr("settings.calview"))
|
Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
ChipRow(
|
Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
options = CalViewType.entries.map { it.key to tr("view.${it.key}") },
|
}
|
||||||
selected = settings.defaultView,
|
Switch(
|
||||||
onSelect = { update(settings.copy(defaultView = it)) },
|
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))
|
Divider(Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
Section(tr("settings.language"))
|
ProfileChapter(vm)
|
||||||
ChipRow(
|
Divider(Modifier.padding(vertical = 16.dp))
|
||||||
options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
|
|
||||||
selected = settings.language,
|
// ---- Termine (synced) ----
|
||||||
onSelect = { update(settings.copy(language = it)) },
|
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))
|
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"))
|
Section(tr("settings.colors"))
|
||||||
ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) }
|
SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
|
||||||
ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) }
|
SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
|
||||||
ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) }
|
SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
|
||||||
ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) }
|
SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
|
||||||
ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = 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))
|
Divider(Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
Section(tr("settings.hourheight"))
|
// ---- Cache (synced) ----
|
||||||
ChipRow(
|
SyncDropdownRow(
|
||||||
options = listOf(
|
tr("settings.cache.range"),
|
||||||
"28" to tr("settings.hourheight.compact"),
|
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")),
|
||||||
"44" to tr("settings.hourheight.normal"),
|
settings.cacheMonths.toString(),
|
||||||
"60" to tr("settings.hourheight.comfort"),
|
{ update(settings.copy(cacheMonths = it.toInt())) },
|
||||||
"80" to tr("settings.hourheight.large"),
|
synced("cache_months"), { toggle("cache_months") },
|
||||||
),
|
|
||||||
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())) },
|
|
||||||
)
|
)
|
||||||
Divider(Modifier.padding(vertical = 16.dp))
|
Divider(Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
Section(tr("settings.cache.title"))
|
// ---- Gerät (device-local: language + contrast) ----
|
||||||
ChipRow(
|
Section(tr("settings.device"))
|
||||||
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")),
|
DeviceDropdownRow(
|
||||||
selected = cacheMonths.toString(),
|
tr("settings.language"),
|
||||||
onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() },
|
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))
|
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
|
@Composable
|
||||||
private fun Section(title: String) {
|
private fun Section(title: String) {
|
||||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
|
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
|
@Composable
|
||||||
private fun ProfileChapter(vm: SettingsViewModel) {
|
private fun ProfileChapter(vm: SettingsViewModel) {
|
||||||
val savedLabel = tr("settings.saved")
|
val savedLabel = tr("settings.saved")
|
||||||
@@ -210,6 +373,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(8.dp))
|
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")) }
|
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
|
||||||
vm.profileMessage?.let {
|
vm.profileMessage?.let {
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
@@ -218,11 +389,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
|
|||||||
Divider(Modifier.padding(vertical = 16.dp))
|
Divider(Modifier.padding(vertical = 16.dp))
|
||||||
|
|
||||||
Section(tr("settings.privacy"))
|
Section(tr("settings.privacy"))
|
||||||
ChipRow(
|
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
|
Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
selected = vm.privateVisibility,
|
Dropdown(
|
||||||
onSelect = vm::changePrivateVisibility,
|
listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
|
||||||
|
vm.privateVisibility,
|
||||||
|
vm::changePrivateVisibility,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.size(6.dp))
|
Spacer(Modifier.size(6.dp))
|
||||||
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Divider(Modifier.padding(vertical = 16.dp))
|
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,
|
private val settingsStore: SettingsStore,
|
||||||
) : ViewModel() {
|
) : 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) {
|
fun apply(settings: AppSettings, onSynced: () -> Unit) {
|
||||||
settingsStore.saveSettings(settings)
|
settingsStore.saveSettings(settings)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { repository.updateSettings(settings) }
|
runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
|
||||||
onSynced()
|
onSynced()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cacheMonths: Int
|
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
|
||||||
get() = settingsStore.cacheMonths
|
var hideMenuButton: Boolean
|
||||||
set(value) { settingsStore.cacheMonths = value }
|
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) ----
|
// ---- Profile chapter (server-backed) ----
|
||||||
|
|
||||||
@@ -38,6 +60,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
var loginName by mutableStateOf("")
|
var loginName by mutableStateOf("")
|
||||||
var email by mutableStateOf("")
|
var email by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
|
var directoryHidden by mutableStateOf(false)
|
||||||
|
private set
|
||||||
var privateVisibility by mutableStateOf("busy")
|
var privateVisibility by mutableStateOf("busy")
|
||||||
private set
|
private set
|
||||||
var groupVisibleId by mutableStateOf(0) // 0 = none
|
var groupVisibleId by mutableStateOf(0) // 0 = none
|
||||||
@@ -51,6 +75,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun onDisplayNameChange(v: String) { displayName = v }
|
fun onDisplayNameChange(v: String) { displayName = v }
|
||||||
fun onEmailChange(v: String) { email = v }
|
fun onEmailChange(v: String) { email = v }
|
||||||
|
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
|
||||||
|
|
||||||
private fun loadProfile() {
|
private fun loadProfile() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -58,13 +83,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
displayName = p.displayName ?: p.username
|
displayName = p.displayName ?: p.username
|
||||||
loginName = p.username
|
loginName = p.username
|
||||||
email = p.email ?: ""
|
email = p.email ?: ""
|
||||||
|
directoryHidden = p.directoryHidden
|
||||||
}
|
}
|
||||||
runCatching { repository.getSettings() }.onSuccess { s ->
|
runCatching { repository.getSettings() }.onSuccess { s ->
|
||||||
privateVisibility = s.privateEventVisibility
|
privateVisibility = s.privateEventVisibility
|
||||||
groupVisibleId = s.groupVisibleCalendarId ?: 0
|
groupVisibleId = s.groupVisibleCalendarId ?: 0
|
||||||
}
|
}
|
||||||
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
|
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 },
|
displayName = displayName.trim().ifEmpty { null },
|
||||||
username = null,
|
username = null,
|
||||||
email = email.trim(),
|
email = email.trim(),
|
||||||
|
directoryHidden = directoryHidden,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onSuccess { profileMessage = savedLabel }
|
.onSuccess { profileMessage = savedLabel }
|
||||||
|
|||||||
@@ -1,58 +1,74 @@
|
|||||||
package com.scarriffle.calendarr.ui.theme
|
package com.scarriffle.calendarr.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
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.domain.model.AppSettings
|
||||||
import com.scarriffle.calendarr.util.colorFromHex
|
import com.scarriffle.calendarr.util.colorFromHex
|
||||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||||
|
|
||||||
/**
|
/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
|
||||||
* The Calendarr brand accent — the green from the iOS `AccentColor` asset
|
* primary colour is unset. */
|
||||||
* (#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]).
|
|
||||||
*/
|
|
||||||
val BrandGreen = Color(0xFF20A050)
|
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
|
@Composable
|
||||||
fun CalendarrTheme(
|
fun CalendarrTheme(
|
||||||
settings: AppSettings = AppSettings(),
|
settings: AppSettings = AppSettings(),
|
||||||
content: @Composable () -> Unit,
|
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(
|
val colors = darkColorScheme(
|
||||||
primary = primary,
|
primary = primary,
|
||||||
onPrimary = primary.contrastingTextColor(),
|
onPrimary = primary.contrastingTextColor(),
|
||||||
primaryContainer = container,
|
primaryContainer = container,
|
||||||
onPrimaryContainer = onContainer,
|
onPrimaryContainer = onContainer,
|
||||||
secondary = primary,
|
secondary = accent,
|
||||||
onSecondary = primary.contrastingTextColor(),
|
onSecondary = accent.contrastingTextColor(),
|
||||||
secondaryContainer = container,
|
secondaryContainer = container,
|
||||||
onSecondaryContainer = onContainer,
|
onSecondaryContainer = onContainer,
|
||||||
tertiary = primary,
|
tertiary = accent,
|
||||||
onTertiary = primary.contrastingTextColor(),
|
onTertiary = accent.contrastingTextColor(),
|
||||||
tertiaryContainer = container,
|
tertiaryContainer = container,
|
||||||
onTertiaryContainer = onContainer,
|
onTertiaryContainer = onContainer,
|
||||||
surfaceTint = primary,
|
surfaceTint = primary,
|
||||||
background = Color(0xFF000000),
|
background = bg,
|
||||||
onBackground = Color(0xFFF2F2F7),
|
onBackground = onBg,
|
||||||
surface = Color(0xFF1C1C1E),
|
surface = surface,
|
||||||
onSurface = Color(0xFFF2F2F7),
|
onSurface = onBg,
|
||||||
surfaceVariant = Color(0xFF2C2C2E),
|
surfaceVariant = surfaceVariant,
|
||||||
onSurfaceVariant = Color(0xFFBEBEC4),
|
onSurfaceVariant = onBg.copy(alpha = 0.7f),
|
||||||
outline = Color(0xFF3A3A3C),
|
outline = line,
|
||||||
)
|
)
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colors,
|
colorScheme = colors,
|
||||||
typography = Typography(),
|
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,
|
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>
|
<resources>
|
||||||
<color name="splash_bg">#000000</color>
|
<color name="splash_bg">#000000</color>
|
||||||
|
<color name="ic_launcher_background">#16703D</color>
|
||||||
|
|
||||||
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<!-- Black window/system bars (matches the app background). -->
|
<!-- 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
|
||||||