Compare commits
14 Commits
7dbaad4b00
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb50c593c | ||
|
|
52dd6e7ff0 | ||
|
|
42187b8254 | ||
|
|
dd347a07f1 | ||
|
|
cc51ede8a5 | ||
|
|
101370719b | ||
|
|
e72ffab4f4 | ||
|
|
3212fe2c6a | ||
|
|
e042f92ec6 | ||
|
|
749eccfaaa | ||
|
|
fd804000c5 | ||
|
|
fdad9c9ba4 | ||
|
|
1f90503104 | ||
|
|
486cc9c622 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,6 +38,7 @@ captures/
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
96
FDROID.md
Normal file
96
FDROID.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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)
|
||||
|
||||
The repo is just static files (APKs + a signed index). Host + generate it on an
|
||||
always-on **Debian/Ubuntu LXC on Proxmox**, and keep the APK build/signing on the
|
||||
Mac (where the keystore lives).
|
||||
|
||||
### One-time: set up the LXC
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu LXC — fdroidserver + the Android build-tools it reads APKs with.
|
||||
apt update
|
||||
apt install -y fdroidserver android-sdk-build-tools default-jdk
|
||||
# (if the distro has no android-sdk-build-tools pkg, install Android command-line
|
||||
# tools and `sdkmanager "build-tools;34.0.0"`, then point $ANDROID_HOME at it)
|
||||
|
||||
# Create the repo once (generates config.yml + an index-signing key)
|
||||
mkdir -p /srv/fdroid && cd /srv/fdroid
|
||||
fdroid init
|
||||
```
|
||||
|
||||
**Serving:** you already run a reverse proxy, so it terminates TLS for
|
||||
`fdroid.scarriffle.com`. Either point the reverse proxy straight at the
|
||||
`/srv/fdroid` directory (no webserver on the LXC needed — just `fdroidserver` to
|
||||
generate), or run a tiny static server on the LXC over plain HTTP and forward to
|
||||
it, e.g. `darkhttpd /srv/fdroid --port 8080` (or `caddy file-server` /
|
||||
`python3 -m http.server`). Only requirement: the public URL is reachable over
|
||||
**HTTPS** (the reverse proxy does that); it's plain static file serving, no
|
||||
directory listing needed.
|
||||
|
||||
### Each release
|
||||
|
||||
```bash
|
||||
# 1. On the MAC: build a SIGNED release APK (F-Droid repos serve APKs, not AABs)
|
||||
./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
# 2. Copy APK + store metadata to the LXC
|
||||
scp app/build/outputs/apk/release/app-release.apk root@lxc:/srv/fdroid/repo/
|
||||
rsync -a "fastlane/metadata/android/" root@lxc:/srv/fdroid/metadata/com.scarriffle.calendarr/
|
||||
|
||||
# 3. On the LXC: regenerate the signed index
|
||||
cd /srv/fdroid && fdroid update -c --pretty
|
||||
```
|
||||
|
||||
Users then open F-Droid → Settings → Repositories → **+** →
|
||||
`https://fdroid.scarriffle.com/fdroid/repo` → Calendarr appears and auto-updates.
|
||||
|
||||
Keep the OLD apks in `repo/` too (users on older versions still update). For each
|
||||
new version bump `versionCode`/`versionName` in `app/build.gradle.kts` first.
|
||||
|
||||
*(Fully hands-off alternative: build on the LXC too — then copy the keystore +
|
||||
`keystore.properties` there and run `assembleRelease` in a checkout. The Mac
|
||||
split above avoids putting the signing key on the server.)*
|
||||
|
||||
---
|
||||
|
||||
## 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
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>.
|
||||
@@ -18,14 +18,14 @@ val keystoreProps = Properties().apply {
|
||||
|
||||
android {
|
||||
namespace = "com.scarriffle.calendarr"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.scarriffle.calendarr"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
targetSdk = 35
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
|
||||
<application
|
||||
android:name=".CalendarrApplication"
|
||||
|
||||
@@ -118,27 +118,31 @@ class CalendarRepository @Inject constructor(
|
||||
|
||||
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
|
||||
|
||||
suspend fun updateSettings(s: AppSettings) = guarded {
|
||||
api.updateSettings(
|
||||
jsonBody(
|
||||
"default_view" to s.defaultView,
|
||||
"week_start_day" to s.weekStartDay,
|
||||
"primary_color" to s.primaryColor,
|
||||
"accent_color" to s.accentColor,
|
||||
"today_color" to s.todayColor,
|
||||
"dim_past_events" to s.dimPastEvents,
|
||||
"text_contrast" to s.textContrast,
|
||||
"line_contrast" to s.lineContrast,
|
||||
"hour_height" to s.hourHeight,
|
||||
"language" to s.language,
|
||||
"month_divider_color" to s.monthDividerColor,
|
||||
"month_label_color" to s.monthLabelColor,
|
||||
"private_event_visibility" to s.privateEventVisibility,
|
||||
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
|
||||
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL),
|
||||
"default_event_duration_minutes" to s.defaultEventDurationMinutes,
|
||||
)
|
||||
).ensureSuccess()
|
||||
/** Push only the values whose sync flag is on (partial update; the server
|
||||
* leaves unsynced columns untouched), plus the account-wide flag map. */
|
||||
suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
|
||||
val body = mutableMapOf<String, Any?>()
|
||||
fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
|
||||
addIf("default_view", s.defaultView)
|
||||
addIf("week_start_day", s.weekStartDay)
|
||||
addIf("dim_past_events", s.dimPastEvents)
|
||||
addIf("hour_height", s.hourHeight)
|
||||
addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
|
||||
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
|
||||
addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
|
||||
addIf("primary_color", s.primaryColor)
|
||||
addIf("accent_color", s.accentColor)
|
||||
addIf("today_color", s.todayColor)
|
||||
addIf("text_color", s.textColor)
|
||||
addIf("bg_color", s.backgroundColor)
|
||||
addIf("line_color", s.lineColor)
|
||||
addIf("month_divider_color", s.monthDividerColor)
|
||||
addIf("month_label_color", s.monthLabelColor)
|
||||
addIf("cache_months", s.cacheMonths)
|
||||
addIf("month_view_paged", s.monthViewPaged)
|
||||
// The flag map is always account-wide; send it every push.
|
||||
body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>)
|
||||
api.updateSettings(jsonBody(body)).ensureSuccess()
|
||||
}
|
||||
|
||||
suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
|
||||
@@ -351,9 +355,9 @@ class CalendarRepository @Inject constructor(
|
||||
calendarId: Int, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = guarded {
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear))
|
||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
@@ -361,8 +365,9 @@ class CalendarRepository @Inject constructor(
|
||||
uid: String, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = guarded {
|
||||
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders))
|
||||
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
|
||||
.ensureSuccess()
|
||||
}
|
||||
|
||||
@@ -499,8 +504,13 @@ class CalendarRepository @Inject constructor(
|
||||
|
||||
// ---- Profile & targeted settings ----
|
||||
|
||||
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded {
|
||||
val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email))
|
||||
suspend fun updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Boolean? = null): String? = guarded {
|
||||
val resp = api.updateProfile(jsonBody(
|
||||
"display_name" to displayName,
|
||||
"username" to username,
|
||||
"email" to email,
|
||||
"directory_hidden" to directoryHidden,
|
||||
))
|
||||
resp.ensureSuccess()
|
||||
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
|
||||
}
|
||||
@@ -609,7 +619,7 @@ class CalendarRepository @Inject constructor(
|
||||
calendarId: Int?, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||
rrule: String? = null, birthYear: Int? = null,
|
||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||
) = jsonBody(
|
||||
buildMap {
|
||||
calendarId?.let { put("calendar_id", it) }
|
||||
@@ -624,6 +634,17 @@ class CalendarRepository @Inject constructor(
|
||||
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,8 +34,13 @@ class SettingsStore @Inject constructor(
|
||||
language = prefs.getString(K_LANGUAGE, null) ?: "de",
|
||||
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
|
||||
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
|
||||
textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF",
|
||||
backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000",
|
||||
lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52",
|
||||
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
|
||||
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60),
|
||||
cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3),
|
||||
monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false),
|
||||
)
|
||||
|
||||
fun saveSettings(s: AppSettings) {
|
||||
@@ -52,11 +57,97 @@ class SettingsStore @Inject constructor(
|
||||
.putString(K_LANGUAGE, s.language)
|
||||
.putString(K_DIVIDER, s.monthDividerColor)
|
||||
.putString(K_LABEL, s.monthLabelColor)
|
||||
.putString(K_TEXT_COLOR, s.textColor)
|
||||
.putString(K_BG_COLOR, s.backgroundColor)
|
||||
.putString(K_LINE_COLOR, s.lineColor)
|
||||
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
|
||||
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
|
||||
.putInt(K_CACHE_MONTHS, s.cacheMonths)
|
||||
.putBoolean(K_MONTH_PAGED, s.monthViewPaged)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// --- Per-setting cross-device sync flags (account-wide; server is authority) ---
|
||||
|
||||
/** Keys this Android client can sync. Excludes language (device "system" has
|
||||
* no server value) and text/line contrast (iOS-style opacity, device-local). */
|
||||
val syncableKeys: List<String> = listOf(
|
||||
"default_view", "week_start_day", "dim_past_events", "hour_height",
|
||||
"default_event_duration_minutes", "default_reminder_minutes",
|
||||
"primary_color", "accent_color", "today_color",
|
||||
"text_color", "bg_color", "line_color",
|
||||
"month_divider_color", "month_label_color",
|
||||
"cache_months", "month_view_paged",
|
||||
)
|
||||
|
||||
private val defaultSync: Map<String, Boolean> = syncableKeys.associateWith { key ->
|
||||
key != "cache_months" && key != "month_view_paged"
|
||||
}
|
||||
|
||||
fun loadSyncFlags(): Map<String, Boolean> {
|
||||
val result = defaultSync.toMutableMap()
|
||||
val raw = prefs.getString(K_SYNC_FLAGS, null)
|
||||
if (!raw.isNullOrBlank()) {
|
||||
runCatching { org.json.JSONObject(raw) }.getOrNull()?.let { obj ->
|
||||
for (key in syncableKeys) if (obj.has(key)) result[key] = obj.getBoolean(key)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun writeSyncFlags(f: Map<String, Boolean>) {
|
||||
prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply()
|
||||
}
|
||||
|
||||
fun storeServerFlags(server: Map<String, Boolean>?) {
|
||||
if (server == null) return
|
||||
val f = loadSyncFlags().toMutableMap()
|
||||
for (key in syncableKeys) server[key]?.let { f[key] = it }
|
||||
writeSyncFlags(f)
|
||||
}
|
||||
|
||||
fun setSyncFlag(key: String, on: Boolean) {
|
||||
val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f)
|
||||
}
|
||||
|
||||
fun setAllSyncFlags(on: Boolean) {
|
||||
val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f)
|
||||
}
|
||||
|
||||
/** Merge a server snapshot with local values honouring the (refreshed) flags:
|
||||
* synced keys take the server value, others keep the local value. Persists
|
||||
* the result and returns the effective settings. */
|
||||
fun applyServerPull(server: AppSettings): AppSettings {
|
||||
storeServerFlags(server.syncFlags)
|
||||
val flags = loadSyncFlags()
|
||||
val local = loadSettings()
|
||||
fun on(key: String) = flags[key] == true
|
||||
val eff = local.copy(
|
||||
defaultView = if (on("default_view")) server.defaultView else local.defaultView,
|
||||
weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay,
|
||||
dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents,
|
||||
hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight,
|
||||
defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes,
|
||||
defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes,
|
||||
primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor,
|
||||
accentColor = if (on("accent_color")) server.accentColor else local.accentColor,
|
||||
todayColor = if (on("today_color")) server.todayColor else local.todayColor,
|
||||
textColor = if (on("text_color")) server.textColor else local.textColor,
|
||||
backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor,
|
||||
lineColor = if (on("line_color")) server.lineColor else local.lineColor,
|
||||
monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor,
|
||||
monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor,
|
||||
cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths,
|
||||
monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged,
|
||||
// Device-local (never synced): language + contrast levels.
|
||||
language = local.language,
|
||||
textContrast = local.textContrast,
|
||||
lineContrast = local.lineContrast,
|
||||
)
|
||||
saveSettings(eff)
|
||||
return eff
|
||||
}
|
||||
|
||||
/** Device-local cache range in months around today (default 3). */
|
||||
var cacheMonths: Int
|
||||
get() = prefs.getInt(K_CACHE_MONTHS, 3)
|
||||
@@ -68,6 +159,25 @@ class SettingsStore @Inject constructor(
|
||||
get() = prefs.getBoolean(K_MONTH_PAGED, false)
|
||||
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
|
||||
|
||||
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
|
||||
var hideMenuButton: Boolean
|
||||
get() = prefs.getBoolean(K_HIDE_MENU, false)
|
||||
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
|
||||
|
||||
/** Whether this device mirrors its Contacts birthdays into the birthday calendar. */
|
||||
var birthdaysSyncEnabled: Boolean
|
||||
get() = prefs.getBoolean(K_BDAY_ENABLED, false)
|
||||
set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply()
|
||||
|
||||
/** Stable per-install id used to scope this device's contact-birthday rows. */
|
||||
val birthdaysDeviceId: String
|
||||
get() {
|
||||
prefs.getString(K_BDAY_DEVICE, null)?.let { return it }
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
prefs.edit().putString(K_BDAY_DEVICE, id).apply()
|
||||
return id
|
||||
}
|
||||
|
||||
// --- Hidden calendars ("source:id") ---
|
||||
|
||||
var hiddenCalendarKeys: Set<String>
|
||||
@@ -89,6 +199,18 @@ class SettingsStore @Inject constructor(
|
||||
get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet()
|
||||
set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply()
|
||||
|
||||
// --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) ---
|
||||
|
||||
var calendarOrder: List<String>
|
||||
get() {
|
||||
val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val arr = org.json.JSONArray(raw)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply()
|
||||
|
||||
private companion object {
|
||||
const val K_DEFAULT_VIEW = "default_view"
|
||||
const val K_WEEK_START = "week_start_day"
|
||||
@@ -102,12 +224,20 @@ class SettingsStore @Inject constructor(
|
||||
const val K_LANGUAGE = "language"
|
||||
const val K_DIVIDER = "month_divider_color"
|
||||
const val K_LABEL = "month_label_color"
|
||||
const val K_TEXT_COLOR = "text_color"
|
||||
const val K_BG_COLOR = "bg_color"
|
||||
const val K_LINE_COLOR = "line_color"
|
||||
const val K_SYNC_FLAGS = "sync_flags"
|
||||
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
|
||||
const val K_DEFAULT_DURATION = "default_event_duration_minutes"
|
||||
const val K_CACHE_MONTHS = "cache_months"
|
||||
const val K_MONTH_PAGED = "month_view_paged"
|
||||
const val K_HIDE_MENU = "hide_menu_button"
|
||||
const val K_BDAY_ENABLED = "birthdays_sync_enabled"
|
||||
const val K_BDAY_DEVICE = "birthdays_device_id"
|
||||
const val K_HIDDEN = "hidden_calendar_keys"
|
||||
const val K_BANISHED = "banished_calendar_keys"
|
||||
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
|
||||
const val K_CAL_ORDER = "calendar_order"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,12 @@ interface CalendarrApi {
|
||||
@DELETE("api/local/events/{uid}")
|
||||
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
|
||||
|
||||
@GET("api/local/calendars/{id}/birthdays")
|
||||
suspend fun getBirthdays(@Path("id") id: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry>
|
||||
|
||||
@POST("api/birthdays/sync-report")
|
||||
suspend fun reportBirthdaySync(@Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
@POST("api/caldav/events")
|
||||
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>
|
||||
|
||||
|
||||
@@ -98,6 +98,19 @@ data class UserProfile(
|
||||
@Json(name = "is_admin") val isAdmin: Boolean = false,
|
||||
@Json(name = "has_avatar") val hasAvatar: Boolean = false,
|
||||
@Json(name = "totp_enabled") val totpEnabled: Boolean = false,
|
||||
@Json(name = "directory_hidden") val directoryHidden: Boolean = false,
|
||||
)
|
||||
|
||||
/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced
|
||||
* rows carry an external_uid ("contact:<deviceId>:<contactId>"); manual ones null. */
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class BirthdayEntry(
|
||||
val uid: String,
|
||||
@Json(name = "external_uid") val externalUid: String? = null,
|
||||
val title: String = "",
|
||||
val month: Int? = null,
|
||||
val day: Int? = null,
|
||||
@Json(name = "birth_year") val birthYear: Int? = null,
|
||||
)
|
||||
|
||||
/** A calendar the user can create events in (resolved from all writable sources). */
|
||||
|
||||
@@ -22,6 +22,11 @@ data class AppSettings(
|
||||
@Json(name = "language") val language: String = "de",
|
||||
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
|
||||
@Json(name = "month_label_color") val monthLabelColor: String = "#7090c0",
|
||||
// Text / background / line colours drive the Material theme (onBackground,
|
||||
// background, outline) so the whole app follows them, matching web/iOS.
|
||||
@Json(name = "text_color") val textColor: String = "#FFFFFF",
|
||||
@Json(name = "bg_color") val backgroundColor: String = "#000000",
|
||||
@Json(name = "line_color") val lineColor: String = "#3A3A52",
|
||||
// How this user's private events appear to other group members: 'hidden' | 'busy'.
|
||||
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
|
||||
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
|
||||
@@ -29,6 +34,11 @@ data class AppSettings(
|
||||
@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"
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
|
||||
onSwitchServer = vm::switchServer,
|
||||
onSettingsChanged = vm::applyLocalSettings,
|
||||
onSettingsSynced = vm::refreshSettings,
|
||||
username = vm.username,
|
||||
serverUrl = vm.serverUrl,
|
||||
)
|
||||
} ?: SplashScreen()
|
||||
}
|
||||
|
||||
@@ -55,6 +55,25 @@ object L10n {
|
||||
"settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
|
||||
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
|
||||
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
|
||||
"settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe",
|
||||
"settings.color.line" to "Linienfarbe",
|
||||
"settings.sync_all" to "Alle synchronisieren",
|
||||
"settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen",
|
||||
"settings.sync_this" to "Zwischen Geräten synchronisieren",
|
||||
"settings.reset" to "Zurücksetzen",
|
||||
"settings.appearance" to "Ansicht",
|
||||
"settings.device" to "Nur auf diesem Gerät",
|
||||
"settings.more" to "Mehr",
|
||||
"settings.hide_menu_button" to "Menü-Button ausblenden",
|
||||
"settings.month_mode" to "Monatswechsel",
|
||||
"settings.month_mode.scroll" to "Fortlaufend scrollen",
|
||||
"settings.month_mode.paged" to "Seitenweise blättern",
|
||||
"settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
|
||||
"settings.directory_hidden" to "Profil verbergen",
|
||||
"settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.",
|
||||
"settings.defaultreminder" to "Standard-Erinnerung",
|
||||
"reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit",
|
||||
"reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher",
|
||||
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
|
||||
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
|
||||
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
|
||||
@@ -122,6 +141,8 @@ object L10n {
|
||||
"accounts.ha.add" to "Home Assistant hinzufügen",
|
||||
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
|
||||
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
|
||||
"filter.sort" to "Sortieren", "filter.done" to "Fertig",
|
||||
"filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten",
|
||||
"filter.button" to "Kalender ein-/ausblenden",
|
||||
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
|
||||
"filter.banish" to "Dauerhaft ausblenden",
|
||||
@@ -136,6 +157,10 @@ object L10n {
|
||||
"local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
|
||||
"local.create" to "Erstellen",
|
||||
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
|
||||
"birthday.calendar_name" to "Geburtstage",
|
||||
"birthday.activate" to "Geburtstagskalender aktivieren",
|
||||
"birthday.import_contacts" to "Aus Kontakten importieren",
|
||||
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
|
||||
"birthday.is_calendar" to "Geburtstagskalender",
|
||||
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
|
||||
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
|
||||
@@ -211,6 +236,25 @@ object L10n {
|
||||
"settings.colors" to "Colors", "settings.color.primary" to "Primary color",
|
||||
"settings.color.accent" to "Accent color", "settings.color.today" to "Today color",
|
||||
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
|
||||
"settings.color.text" to "Text color", "settings.color.background" to "Background color",
|
||||
"settings.color.line" to "Line color",
|
||||
"settings.sync_all" to "Sync all",
|
||||
"settings.sync_all.desc" to "Share these settings across your devices",
|
||||
"settings.sync_this" to "Sync across devices",
|
||||
"settings.reset" to "Reset",
|
||||
"settings.appearance" to "View",
|
||||
"settings.device" to "This device only",
|
||||
"settings.more" to "More",
|
||||
"settings.hide_menu_button" to "Hide menu button",
|
||||
"settings.month_mode" to "Month switching",
|
||||
"settings.month_mode.scroll" to "Continuous scroll",
|
||||
"settings.month_mode.paged" to "Page by page",
|
||||
"settings.device.footer" to "These settings apply to this device only and are not synced.",
|
||||
"settings.directory_hidden" to "Hide profile",
|
||||
"settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.",
|
||||
"settings.defaultreminder" to "Default reminder",
|
||||
"reminder.off" to "Off", "reminder.at_start" to "At start time",
|
||||
"reminder.1d" to "1 day before", "reminder.1w" to "1 week before",
|
||||
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
|
||||
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
|
||||
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
|
||||
@@ -278,6 +322,8 @@ object L10n {
|
||||
"accounts.ha.add" to "Add Home Assistant",
|
||||
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
|
||||
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
|
||||
"filter.sort" to "Sort", "filter.done" to "Done",
|
||||
"filter.move_up" to "Move up", "filter.move_down" to "Move down",
|
||||
"filter.button" to "Show/hide calendars",
|
||||
"filter.sync_error" to "Sync failed",
|
||||
"filter.banish" to "Hide permanently",
|
||||
@@ -292,6 +338,10 @@ object L10n {
|
||||
"local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
|
||||
"local.create" to "Create",
|
||||
"birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday",
|
||||
"birthday.calendar_name" to "Birthdays",
|
||||
"birthday.activate" to "Enable birthday calendar",
|
||||
"birthday.import_contacts" to "Import from contacts",
|
||||
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
|
||||
"birthday.is_calendar" to "Birthday calendar",
|
||||
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
|
||||
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
|
||||
|
||||
@@ -62,12 +62,12 @@ class MainViewModel @Inject constructor(
|
||||
_route.value = computeRoute()
|
||||
}
|
||||
|
||||
/** Pull appearance settings from the server, caching them locally. */
|
||||
/** Pull settings from the server and merge them with local values honouring
|
||||
* each setting's sync flag (synced keys take the server value). */
|
||||
fun refreshSettings() {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.getSettings() }.onSuccess { s ->
|
||||
settingsStore.saveSettings(s)
|
||||
_settings.value = s
|
||||
_settings.value = settingsStore.applyServerPull(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,8 +233,8 @@ fun AccountsScreen(
|
||||
}
|
||||
|
||||
when (addDialog) {
|
||||
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color, isBirthday, notify ->
|
||||
vm.addLocal(name, color, onChanged, isBirthday, notify); addDialog = null
|
||||
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color ->
|
||||
vm.addLocal(name, color, onChanged); addDialog = null
|
||||
}
|
||||
AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c ->
|
||||
vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null
|
||||
@@ -488,47 +488,14 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
|
||||
// ---- Add dialogs ----
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String, Boolean, Int?) -> Unit) {
|
||||
private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var birthday by remember { mutableStateOf(false) }
|
||||
var notify by remember { mutableStateOf(-1) } // -1 off, 0 on the day, N days before
|
||||
var notifyMenu by remember { mutableStateOf(false) }
|
||||
val color = "#34a853"
|
||||
FormDialog(
|
||||
tr("accounts.local.add"), onDismiss,
|
||||
confirmEnabled = name.isNotBlank(),
|
||||
onConfirm = { onConfirm(name.trim(), color, birthday, if (birthday && notify >= 0) notify else null) },
|
||||
) {
|
||||
FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.size(8.dp))
|
||||
FilterChip(selected = birthday, onClick = { birthday = !birthday }, label = { Text(tr("birthday.is_calendar")) })
|
||||
if (birthday) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = notify >= 0,
|
||||
onClick = { notifyMenu = true },
|
||||
label = { Text("${tr("birthday.notify")}: ${notifyLabel(notify)}") },
|
||||
)
|
||||
DropdownMenu(expanded = notifyMenu, onDismissRequest = { notifyMenu = false }) {
|
||||
listOf(-1, 0, 1, 2, 3, 7).forEach { d ->
|
||||
DropdownMenuItem(text = { Text(notifyLabel(d)) }, onClick = { notify = d; notifyMenu = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun notifyLabel(d: Int): String = when {
|
||||
d < 0 -> tr("birthday.notify.off")
|
||||
d == 0 -> tr("birthday.notify.same_day")
|
||||
d == 1 -> tr("birthday.notify.one_day")
|
||||
else -> tr("birthday.notify.days", d)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -8,8 +7,6 @@ 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.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -32,33 +29,32 @@ import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Minimal "new birthday" mask (parity with iOS/web): pick a birthday calendar,
|
||||
* enter a name and a date (with an optional "year unknown"). Saves an all-day,
|
||||
* yearly-recurring local event; the server adds the age suffix and cake icon.
|
||||
* 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(
|
||||
calendars: List<LocalCalendar>,
|
||||
calendar: LocalCalendar?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (name: String, date: LocalDate, yearKnown: Boolean, calendarId: Int) -> 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 selectedCalId by remember { mutableStateOf(calendars.firstOrNull()?.id ?: -1) }
|
||||
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
var calMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val hasCalendars = calendars.isNotEmpty()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(tr("birthday.new_title")) },
|
||||
text = {
|
||||
Column {
|
||||
if (!hasCalendars) {
|
||||
Text(tr("birthday.no_calendars"), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (calendar == null) {
|
||||
Text(tr("birthday.activate_hint"), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
name, { name = it },
|
||||
@@ -75,28 +71,20 @@ fun BirthdayDialog(
|
||||
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
|
||||
label = { Text(tr("birthday.year_unknown")) },
|
||||
)
|
||||
if (calendars.size > 1) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = false, onClick = { calMenu = true },
|
||||
label = { Text(calendars.firstOrNull { it.id == selectedCalId }?.name ?: tr("birthday.target")) },
|
||||
)
|
||||
DropdownMenu(expanded = calMenu, onDismissRequest = { calMenu = false }) {
|
||||
calendars.forEach { c ->
|
||||
DropdownMenuItem(text = { Text(c.name) }, onClick = { selectedCalId = c.id; calMenu = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = hasCalendars && name.isNotBlank(),
|
||||
onClick = { onSave(name.trim(), pickedDate, !yearUnknown, selectedCalId) },
|
||||
) { Text(tr("common.save")) }
|
||||
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")) } },
|
||||
)
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,17 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -49,6 +51,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -70,13 +73,14 @@ import com.scarriffle.calendarr.ui.event.EventDetailScreen
|
||||
import com.scarriffle.calendarr.ui.event.EventEditorSheet
|
||||
import com.scarriffle.calendarr.ui.groups.GroupIcon
|
||||
import com.scarriffle.calendarr.ui.groups.GroupsScreen
|
||||
import com.scarriffle.calendarr.ui.menu.MenuSheet
|
||||
import com.scarriffle.calendarr.ui.menu.MenuScreen
|
||||
import com.scarriffle.calendarr.ui.profile.ProfileScreen
|
||||
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
|
||||
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
|
||||
private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
|
||||
|
||||
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
|
||||
|
||||
@@ -87,6 +91,8 @@ fun CalendarScreen(
|
||||
onSwitchServer: () -> Unit,
|
||||
onSettingsChanged: (AppSettings) -> Unit,
|
||||
onSettingsSynced: () -> Unit,
|
||||
username: String = "",
|
||||
serverUrl: String = "",
|
||||
vm: CalendarViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
@@ -124,16 +130,48 @@ fun CalendarScreen(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
// Contacts birthday import (mirrors iOS): request permission on demand; sync
|
||||
// this device's contact birthdays into the birthday calendar.
|
||||
val deviceName = android.os.Build.MODEL ?: "Android"
|
||||
fun runBirthdayImport() {
|
||||
vm.birthdaysSyncEnabled = true
|
||||
vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName)
|
||||
}
|
||||
val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { granted -> if (granted) runBirthdayImport() }
|
||||
fun startBirthdayImport() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.READ_CONTACTS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
if (vm.birthdaysSyncEnabled &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.READ_CONTACTS
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) runBirthdayImport()
|
||||
}
|
||||
|
||||
var viewMenuOpen by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showFilter by remember { mutableStateOf(false) }
|
||||
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
||||
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
||||
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
||||
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var fabMenuOpen by remember { mutableStateOf(false) }
|
||||
var showBirthday by remember { mutableStateOf(false) }
|
||||
var birthdayCals by remember { mutableStateOf<List<com.scarriffle.calendarr.domain.model.LocalCalendar>>(emptyList()) }
|
||||
var birthdayCal by remember { mutableStateOf<com.scarriffle.calendarr.domain.model.LocalCalendar?>(null) }
|
||||
val birthdayScope = rememberCoroutineScope()
|
||||
val birthdayCalName = tr("birthday.calendar_name")
|
||||
|
||||
// Side navigation drawer (calendars + groups + view + menu).
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }
|
||||
androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) {
|
||||
if (drawerState.isOpen) vm.loadAllCalendars()
|
||||
}
|
||||
|
||||
// Continuous month scrolling
|
||||
val monthListState = rememberLazyListState()
|
||||
@@ -155,6 +193,18 @@ fun CalendarScreen(
|
||||
if (isMonth) todaySignal++ else vm.moveToToday()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawerContent(
|
||||
state = state, vm = vm, username = username, serverUrl = serverUrl,
|
||||
calendars = drawerCalendars,
|
||||
onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU },
|
||||
onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() },
|
||||
onClose = { drawerScope.launch { drawerState.close() } },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CompactTopBar(
|
||||
@@ -162,11 +212,11 @@ fun CalendarScreen(
|
||||
viewType = state.viewType,
|
||||
loading = state.isLoading || state.isBackgroundCaching,
|
||||
viewMenuOpen = viewMenuOpen,
|
||||
onMenu = { showMenu = true },
|
||||
showMenuButton = !state.hideMenuButton,
|
||||
onMenu = { drawerScope.launch { drawerState.open() } },
|
||||
onPrev = { goPrev() },
|
||||
onToday = { goToday() },
|
||||
onNext = { goNext() },
|
||||
onFilter = { showFilter = true },
|
||||
onViewMenuToggle = { viewMenuOpen = it },
|
||||
onSelectView = { vm.setViewType(it) },
|
||||
)
|
||||
@@ -238,35 +288,10 @@ fun CalendarScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sheets ----
|
||||
|
||||
if (showMenu) {
|
||||
MenuSheet(
|
||||
isAdmin = false,
|
||||
groups = state.groups,
|
||||
activeGroup = state.activeGroup,
|
||||
onSwitchGroup = { showMenu = false; vm.switchGroup(it) },
|
||||
onDismiss = { showMenu = false },
|
||||
onProfile = { showMenu = false; overlay = Overlay.PROFILE },
|
||||
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
|
||||
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
|
||||
onGroups = { showMenu = false; overlay = Overlay.GROUPS },
|
||||
onSync = { showMenu = false; vm.syncWithServer() },
|
||||
onLogout = { showMenu = false; onLogout() },
|
||||
onSwitchServer = { showMenu = false; onSwitchServer() },
|
||||
)
|
||||
}
|
||||
|
||||
if (showFilter) {
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { vm.loadAllCalendars() }
|
||||
CalendarFilterSheet(
|
||||
events = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) },
|
||||
vm = vm,
|
||||
onDismiss = { showFilter = false },
|
||||
)
|
||||
}
|
||||
|
||||
dayPreview?.let { date ->
|
||||
DayPreviewDialog(
|
||||
date = date,
|
||||
@@ -280,12 +305,14 @@ fun CalendarScreen(
|
||||
}
|
||||
|
||||
if (showBirthday) {
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCals = vm.birthdayCalendars() }
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
|
||||
BirthdayDialog(
|
||||
calendars = birthdayCals,
|
||||
calendar = birthdayCal,
|
||||
onDismiss = { showBirthday = false },
|
||||
onSave = { name, date, yearKnown, calId ->
|
||||
vm.createBirthday(calId, name, date, yearKnown) {}
|
||||
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
|
||||
},
|
||||
)
|
||||
@@ -337,18 +364,30 @@ fun CalendarScreen(
|
||||
}
|
||||
|
||||
when (overlay) {
|
||||
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE })
|
||||
Overlay.MENU -> MenuScreen(
|
||||
username = username,
|
||||
serverUrl = serverUrl,
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onProfile = { overlay = Overlay.PROFILE },
|
||||
onAppearance = { overlay = Overlay.SETTINGS },
|
||||
onAccounts = { overlay = Overlay.ACCOUNTS },
|
||||
onGroups = { overlay = Overlay.GROUPS },
|
||||
onSync = { overlay = Overlay.NONE; vm.syncWithServer() },
|
||||
onSwitchServer = onSwitchServer,
|
||||
onLogout = onLogout,
|
||||
)
|
||||
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.MENU })
|
||||
Overlay.SETTINGS -> SettingsScreen(
|
||||
onClose = { overlay = Overlay.NONE; vm.refreshMonthViewMode() },
|
||||
onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() },
|
||||
onSettingsChanged = onSettingsChanged,
|
||||
onSettingsSynced = onSettingsSynced,
|
||||
)
|
||||
Overlay.ACCOUNTS -> AccountsScreen(
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onClose = { overlay = Overlay.MENU },
|
||||
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
|
||||
)
|
||||
Overlay.GROUPS -> GroupsScreen(
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onClose = { overlay = Overlay.MENU },
|
||||
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
|
||||
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
|
||||
)
|
||||
@@ -445,11 +484,11 @@ private fun CompactTopBar(
|
||||
viewType: CalViewType,
|
||||
loading: Boolean,
|
||||
viewMenuOpen: Boolean,
|
||||
showMenuButton: Boolean,
|
||||
onMenu: () -> Unit,
|
||||
onPrev: () -> Unit,
|
||||
onToday: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onFilter: () -> Unit,
|
||||
onViewMenuToggle: (Boolean) -> Unit,
|
||||
onSelectView: (CalViewType) -> Unit,
|
||||
) {
|
||||
@@ -461,6 +500,8 @@ private fun CompactTopBar(
|
||||
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Menu (hamburger) on the left — the drawer opens from the left.
|
||||
if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
|
||||
CompactIcon(Icons.Filled.ChevronLeft, onPrev)
|
||||
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
|
||||
Text(tr("nav.today"), fontSize = 13.sp)
|
||||
@@ -482,7 +523,6 @@ private fun CompactTopBar(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
|
||||
Box {
|
||||
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
|
||||
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
|
||||
@@ -495,7 +535,6 @@ private fun CompactTopBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
|
||||
}
|
||||
Divider(
|
||||
thickness = 0.5.dp,
|
||||
|
||||
@@ -62,6 +62,8 @@ data class CalendarUiState(
|
||||
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"
|
||||
@@ -127,12 +129,78 @@ class CalendarViewModel @Inject constructor(
|
||||
banishedKeys = settingsStore.banishedCalendarKeys,
|
||||
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
|
||||
monthViewPaged = settingsStore.monthViewPaged,
|
||||
hideMenuButton = settingsStore.hideMenuButton,
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-read the device-local month-view mode (called when settings close). */
|
||||
/** Re-read the device-local view prefs (called when settings close). */
|
||||
fun refreshMonthViewMode() {
|
||||
_state.update { it.copy(monthViewPaged = settingsStore.monthViewPaged) }
|
||||
_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. */
|
||||
@@ -503,10 +571,18 @@ class CalendarViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Birthday calendars the user may write to (own or shared read/write). */
|
||||
suspend fun birthdayCalendars(): List<LocalCalendar> =
|
||||
/** The user's single birthday calendar, or null if not activated yet. */
|
||||
suspend fun birthdayCalendar(): LocalCalendar? =
|
||||
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
|
||||
.filter { it.isBirthday && (it.owned || it.permission == "read_write") }
|
||||
.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
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -39,6 +40,9 @@ import java.time.format.DateTimeFormatter
|
||||
|
||||
private val GUTTER = 48.dp
|
||||
|
||||
/** An all-day event laid out as a bar spanning columns [start]..[end] in lane [lane]. */
|
||||
private data class AllDayBar(val ev: CalEvent, val start: Int, val end: Int, val lane: Int)
|
||||
|
||||
@Composable
|
||||
fun TimeGridView(
|
||||
days: List<LocalDate>,
|
||||
@@ -76,16 +80,48 @@ fun TimeGridView(
|
||||
}
|
||||
}
|
||||
|
||||
// All-day row
|
||||
val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
||||
if (allDay.any { it.second.isNotEmpty() }) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
||||
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
|
||||
Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
|
||||
// All-day row: continuous bars spanning an event's start→end columns and
|
||||
// packed into lanes, instead of repeating a multi-day event once per day.
|
||||
val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
||||
if (perDay.any { it.isNotEmpty() }) {
|
||||
// event id -> [minCol, maxCol] within this week
|
||||
val range = LinkedHashMap<String, IntArray>()
|
||||
val evById = HashMap<String, CalEvent>()
|
||||
perDay.forEachIndexed { i, evs ->
|
||||
evs.forEach { ev ->
|
||||
evById[ev.id] = ev
|
||||
val r = range[ev.id]
|
||||
if (r == null) range[ev.id] = intArrayOf(i, i) else r[1] = i
|
||||
}
|
||||
allDay.forEach { (_, evs) ->
|
||||
Column(Modifier.weight(1f).padding(horizontal = 1.dp)) {
|
||||
evs.forEach { ev -> AllDayChip(ev, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) }
|
||||
}
|
||||
// earlier / longer first, then greedily pack into lanes
|
||||
val spans = range.entries
|
||||
.map { Triple(evById.getValue(it.key), it.value[0], it.value[1]) }
|
||||
.sortedWith(compareBy({ it.second }, { -(it.third - it.second) }))
|
||||
val laneEnd = ArrayList<Int>()
|
||||
val bars = ArrayList<AllDayBar>()
|
||||
for ((ev, s, e) in spans) {
|
||||
var lane = 0
|
||||
while (lane < laneEnd.size && laneEnd[lane] >= s) lane++
|
||||
if (lane == laneEnd.size) laneEnd.add(e) else laneEnd[lane] = e
|
||||
bars.add(AllDayBar(ev, s, e, lane))
|
||||
}
|
||||
val laneCount = (bars.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
||||
for (lane in 0 until laneCount) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
|
||||
if (lane == 0) Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
var col = 0
|
||||
bars.filter { it.lane == lane }.sortedBy { it.start }.forEach { bar ->
|
||||
if (bar.start > col) { Spacer(Modifier.weight((bar.start - col).toFloat())); col = bar.start }
|
||||
Box(Modifier.weight((bar.end - bar.start + 1).toFloat()).padding(horizontal = 1.dp)) {
|
||||
AllDayChip(bar.ev, dimmed = dimPast && bar.ev.endDate.isBefore(now), onClick = { onEventClick(bar.ev) })
|
||||
}
|
||||
col = bar.end + 1
|
||||
}
|
||||
if (col < days.size) Spacer(Modifier.weight((days.size - col).toFloat()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
124
app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt
Normal file
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,146 +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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
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.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.domain.model.Group
|
||||
import com.scarriffle.calendarr.ui.groups.GroupIcon
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MenuSheet(
|
||||
isAdmin: Boolean,
|
||||
groups: List<Group>,
|
||||
activeGroup: Group?,
|
||||
onSwitchGroup: (Group?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onProfile: () -> Unit,
|
||||
onAppearance: () -> Unit,
|
||||
onAccounts: () -> Unit,
|
||||
onGroups: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onSwitchServer: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
// Inset for the system navigation bar so the last rows aren't hidden
|
||||
// behind the Android nav buttons; scroll as a fallback on short screens.
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 12.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)
|
||||
// Group view switch (personal + each group) — moved here from the
|
||||
// top bar to free up space. Group *management* stays below.
|
||||
if (groups.isNotEmpty()) {
|
||||
Divider(Modifier.padding(vertical = 4.dp))
|
||||
MenuSectionLabel(tr("groups.view"))
|
||||
GroupSwitchRow(
|
||||
label = tr("group.switch.personal"),
|
||||
checked = activeGroup == null,
|
||||
leading = { Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||
onClick = { onSwitchGroup(null) },
|
||||
)
|
||||
groups.forEach { g ->
|
||||
GroupSwitchRow(
|
||||
label = g.name,
|
||||
checked = activeGroup?.id == g.id,
|
||||
leading = { GroupIcon(g.icon) },
|
||||
onClick = { onSwitchGroup(g) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 4.dp))
|
||||
MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuSectionLabel(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A group-view choice (personal or a specific group) with a trailing check. */
|
||||
@Composable
|
||||
private fun GroupSwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
leading: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leading()
|
||||
Spacer(Modifier.width(18.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
if (checked) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,32 @@ package com.scarriffle.calendarr.ui.settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -36,6 +37,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -56,8 +58,11 @@ import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.toHex
|
||||
|
||||
private val PALETTE = listOf(
|
||||
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
|
||||
// Canonical default colours (single source for the reset buttons).
|
||||
private val DEFAULT_COLORS = mapOf(
|
||||
"primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4",
|
||||
"text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52",
|
||||
"month_divider_color" to "#7090C0", "month_label_color" to "#7090C0",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -70,8 +75,6 @@ fun SettingsScreen(
|
||||
) {
|
||||
val initialSettings = LocalAppSettings.current
|
||||
var settings by remember { mutableStateOf(initialSettings) }
|
||||
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
|
||||
var monthPaged by remember { mutableStateOf(vm.monthViewPaged) }
|
||||
|
||||
fun update(newSettings: AppSettings) {
|
||||
settings = newSettings
|
||||
@@ -79,6 +82,10 @@ fun SettingsScreen(
|
||||
vm.apply(newSettings, onSettingsSynced)
|
||||
}
|
||||
|
||||
// Per-row sync toggle: enabling adopts this device's current value.
|
||||
fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) }
|
||||
fun synced(key: String) = vm.syncFlags[key] == true
|
||||
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -92,117 +99,254 @@ fun SettingsScreen(
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
|
||||
// Global "sync everything" master switch.
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = vm.syncableKeys.all { vm.syncFlags[it] == true },
|
||||
onCheckedChange = { on -> vm.setAllSync(on, settings, onSettingsSynced) },
|
||||
)
|
||||
}
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
ProfileChapter(vm)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Section(tr("settings.default_duration"))
|
||||
ChipRow(
|
||||
options = 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",
|
||||
),
|
||||
selected = settings.defaultEventDurationMinutes.toString(),
|
||||
onSelect = { update(settings.copy(defaultEventDurationMinutes = it.toInt())) },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// ---- Termine (synced) ----
|
||||
Section(tr("settings.calview"))
|
||||
ChipRow(
|
||||
options = CalViewType.entries.map { it.key to tr("view.${it.key}") },
|
||||
selected = settings.defaultView,
|
||||
onSelect = { update(settings.copy(defaultView = it)) },
|
||||
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") },
|
||||
)
|
||||
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)) })
|
||||
}
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(tr("settings.month_paged"), style = MaterialTheme.typography.bodyLarge)
|
||||
Switch(checked = monthPaged, onCheckedChange = { monthPaged = it; vm.monthViewPaged = it })
|
||||
}
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.language"))
|
||||
ChipRow(
|
||||
options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
|
||||
selected = settings.language,
|
||||
onSelect = { update(settings.copy(language = it)) },
|
||||
SyncDropdownRow(
|
||||
tr("settings.defaultreminder"),
|
||||
reminderOptions(),
|
||||
(settings.defaultReminderMinutes ?: -1).toString(),
|
||||
{ update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) },
|
||||
synced("default_reminder_minutes"), { toggle("default_reminder_minutes") },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// ---- Ansicht (synced) ----
|
||||
Section(tr("settings.appearance"))
|
||||
SyncDropdownRow(
|
||||
tr("settings.defaultview"),
|
||||
CalViewType.entries.map { it.key to tr("view.${it.key}") },
|
||||
settings.defaultView,
|
||||
{ update(settings.copy(defaultView = it)) },
|
||||
synced("default_view"), { toggle("default_view") },
|
||||
)
|
||||
SyncDropdownRow(
|
||||
tr("settings.firstweekday"),
|
||||
listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
|
||||
settings.weekStartDay,
|
||||
{ update(settings.copy(weekStartDay = it)) },
|
||||
synced("week_start_day"), { toggle("week_start_day") },
|
||||
)
|
||||
SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") })
|
||||
SyncDropdownRow(
|
||||
tr("settings.month_mode"),
|
||||
listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")),
|
||||
settings.monthViewPaged.toString(),
|
||||
{ update(settings.copy(monthViewPaged = it.toBoolean())) },
|
||||
synced("month_view_paged"), { toggle("month_view_paged") },
|
||||
)
|
||||
SyncDropdownRow(
|
||||
tr("settings.hourheight"),
|
||||
listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")),
|
||||
settings.hourHeight.toString(),
|
||||
{ update(settings.copy(hourHeight = it.toInt())) },
|
||||
synced("hour_height"), { toggle("hour_height") },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// ---- Farben (synced) ----
|
||||
Section(tr("settings.colors"))
|
||||
ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) }
|
||||
ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) }
|
||||
ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) }
|
||||
ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) }
|
||||
ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) }
|
||||
SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
|
||||
SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
|
||||
SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
|
||||
SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
|
||||
SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) }
|
||||
SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) }
|
||||
SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) }
|
||||
SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) }
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.hourheight"))
|
||||
ChipRow(
|
||||
options = listOf(
|
||||
"28" to tr("settings.hourheight.compact"),
|
||||
"44" to tr("settings.hourheight.normal"),
|
||||
"60" to tr("settings.hourheight.comfort"),
|
||||
"80" to tr("settings.hourheight.large"),
|
||||
),
|
||||
selected = settings.hourHeight.toString(),
|
||||
onSelect = { update(settings.copy(hourHeight = it.toInt())) },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
Section(tr("settings.textcontrast"))
|
||||
ChipRow(
|
||||
options = listOf(
|
||||
"1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"),
|
||||
"3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"),
|
||||
),
|
||||
selected = settings.textContrast.toString(),
|
||||
onSelect = { update(settings.copy(textContrast = it.toInt())) },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Section(tr("settings.linecontrast"))
|
||||
ChipRow(
|
||||
options = listOf(
|
||||
"1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"),
|
||||
"3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"),
|
||||
),
|
||||
selected = settings.lineContrast.toString(),
|
||||
onSelect = { update(settings.copy(lineContrast = it.toInt())) },
|
||||
// ---- Cache (synced) ----
|
||||
SyncDropdownRow(
|
||||
tr("settings.cache.range"),
|
||||
listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
|
||||
settings.cacheMonths.toString(),
|
||||
{ update(settings.copy(cacheMonths = it.toInt())) },
|
||||
synced("cache_months"), { toggle("cache_months") },
|
||||
)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.cache.title"))
|
||||
ChipRow(
|
||||
options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
|
||||
selected = cacheMonths.toString(),
|
||||
onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() },
|
||||
)
|
||||
// ---- Gerät (device-local: language + contrast) ----
|
||||
Section(tr("settings.device"))
|
||||
DeviceDropdownRow(
|
||||
tr("settings.language"),
|
||||
listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
|
||||
settings.language,
|
||||
) { update(settings.copy(language = it)) }
|
||||
DeviceDropdownRow(
|
||||
tr("settings.textcontrast"),
|
||||
listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")),
|
||||
settings.textContrast.toString(),
|
||||
) { update(settings.copy(textContrast = it.toInt())) }
|
||||
DeviceDropdownRow(
|
||||
tr("settings.linecontrast"),
|
||||
listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")),
|
||||
settings.lineContrast.toString(),
|
||||
) { update(settings.copy(lineContrast = it.toInt())) }
|
||||
var hideMenu by remember { mutableStateOf(vm.hideMenuButton) }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.size(44.dp))
|
||||
Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it })
|
||||
}
|
||||
Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp))
|
||||
Spacer(Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reminderOptions(): List<Pair<String, String>> = listOf(
|
||||
"-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min",
|
||||
"30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
|
||||
/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */
|
||||
|
||||
/** Leading per-row sync toggle: highlighted = synced across devices. */
|
||||
@Composable
|
||||
private fun SyncIcon(on: Boolean, onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = tr("settings.sync_this"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Compact dropdown trigger (a small outlined chip) — much shorter than a full
|
||||
// OutlinedTextField, so the settings rows aren't inflated.
|
||||
@Composable
|
||||
private fun Dropdown(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected
|
||||
Box {
|
||||
Row(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
|
||||
.clickable { expanded = true }
|
||||
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(selectedLabel, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { (key, label) ->
|
||||
DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncDropdownRow(
|
||||
label: String,
|
||||
options: List<Pair<String, String>>,
|
||||
selected: String,
|
||||
onSelect: (String) -> Unit,
|
||||
synced: Boolean,
|
||||
onToggleSync: () -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SyncIcon(synced, onToggleSync)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Dropdown(options, selected, onSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceDropdownRow(
|
||||
label: String,
|
||||
options: List<Pair<String, String>>,
|
||||
selected: String,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.size(44.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Dropdown(options, selected, onSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SyncIcon(synced, onToggleSync)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = onChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncColorRow(
|
||||
syncKey: String,
|
||||
label: String,
|
||||
current: String,
|
||||
synced: Boolean,
|
||||
onToggleSync: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SyncIcon(synced, onToggleSync)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Box(
|
||||
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
|
||||
.clickable { showPicker = true },
|
||||
)
|
||||
TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
||||
Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
if (showPicker) {
|
||||
ColorPickerDialog(
|
||||
initial = current,
|
||||
title = label,
|
||||
onDismiss = { showPicker = false },
|
||||
onConfirm = { showPicker = false; onPick(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProfileChapter(vm: SettingsViewModel) {
|
||||
val savedLabel = tr("settings.saved")
|
||||
@@ -229,6 +373,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge)
|
||||
Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange)
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
|
||||
vm.profileMessage?.let {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
@@ -237,11 +389,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Section(tr("settings.privacy"))
|
||||
ChipRow(
|
||||
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
|
||||
selected = vm.privateVisibility,
|
||||
onSelect = vm::changePrivateVisibility,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Dropdown(
|
||||
listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
|
||||
vm.privateVisibility,
|
||||
vm::changePrivateVisibility,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
@@ -275,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,23 +19,40 @@ class SettingsViewModel @Inject constructor(
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Persist locally immediately, then sync to the server in the background. */
|
||||
/** Persist locally immediately, then push the synced values to the server. */
|
||||
fun apply(settings: AppSettings, onSynced: () -> Unit) {
|
||||
settingsStore.saveSettings(settings)
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.updateSettings(settings) }
|
||||
runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
|
||||
onSynced()
|
||||
}
|
||||
}
|
||||
|
||||
var cacheMonths: Int
|
||||
get() = settingsStore.cacheMonths
|
||||
set(value) { settingsStore.cacheMonths = value }
|
||||
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
|
||||
var hideMenuButton: Boolean
|
||||
get() = settingsStore.hideMenuButton
|
||||
set(value) { settingsStore.hideMenuButton = value }
|
||||
|
||||
/** Device-local: month view as horizontal paged (swipe) vs. scroll feed. */
|
||||
var monthViewPaged: Boolean
|
||||
get() = settingsStore.monthViewPaged
|
||||
set(value) { settingsStore.monthViewPaged = 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) ----
|
||||
|
||||
@@ -43,6 +60,8 @@ class SettingsViewModel @Inject constructor(
|
||||
var loginName by mutableStateOf("")
|
||||
var email by mutableStateOf("")
|
||||
private set
|
||||
var directoryHidden by mutableStateOf(false)
|
||||
private set
|
||||
var privateVisibility by mutableStateOf("busy")
|
||||
private set
|
||||
var groupVisibleId by mutableStateOf(0) // 0 = none
|
||||
@@ -56,6 +75,7 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun onDisplayNameChange(v: String) { displayName = v }
|
||||
fun onEmailChange(v: String) { email = v }
|
||||
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
|
||||
|
||||
private fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
@@ -63,13 +83,16 @@ class SettingsViewModel @Inject constructor(
|
||||
displayName = p.displayName ?: p.username
|
||||
loginName = p.username
|
||||
email = p.email ?: ""
|
||||
directoryHidden = p.directoryHidden
|
||||
}
|
||||
runCatching { repository.getSettings() }.onSuccess { s ->
|
||||
privateVisibility = s.privateEventVisibility
|
||||
groupVisibleId = s.groupVisibleCalendarId ?: 0
|
||||
}
|
||||
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
|
||||
ownLocalCalendars = cals.filter { it.owned && !it.group }
|
||||
// A birthday calendar may be shared directly, but never stand in
|
||||
// as the group-visible personal calendar.
|
||||
ownLocalCalendars = cals.filter { it.owned && !it.group && !it.isBirthday }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +104,7 @@ class SettingsViewModel @Inject constructor(
|
||||
displayName = displayName.trim().ifEmpty { null },
|
||||
username = null,
|
||||
email = email.trim(),
|
||||
directoryHidden = directoryHidden,
|
||||
)
|
||||
}
|
||||
.onSuccess { profileMessage = savedLabel }
|
||||
|
||||
@@ -7,50 +7,60 @@ import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.domain.model.AppSettings
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
|
||||
/**
|
||||
* The Calendarr brand accent — the green from the iOS `AccentColor` asset
|
||||
* (#20A050). This drives the global control tint (buttons, FAB, switches,
|
||||
* top bar) regardless of the server's per-calendar colours, matching iOS
|
||||
* where the app tint is fixed and `primary_color` only styles calendar
|
||||
* elements (e.g. the "today" highlight, read from [AppSettings]).
|
||||
*/
|
||||
/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
|
||||
* primary colour is unset. */
|
||||
val BrandGreen = Color(0xFF20A050)
|
||||
|
||||
/**
|
||||
* Fully dynamic theme: every colour is derived from [AppSettings] so the whole
|
||||
* app follows the user's palette (matching the web client). primary/accent tint
|
||||
* the controls; background/text/line drive `background`/`onBackground`/`outline`,
|
||||
* which the calendar views already read (grid, secondary text). "today", divider
|
||||
* and label colours are read directly in the views.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarrTheme(
|
||||
settings: AppSettings = AppSettings(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val primary = BrandGreen
|
||||
val primary = colorFromHex(settings.primaryColor, BrandGreen)
|
||||
val accent = colorFromHex(settings.accentColor, primary)
|
||||
val bg = colorFromHex(settings.backgroundColor, Color(0xFF000000))
|
||||
val onBg = colorFromHex(settings.textColor, Color(0xFFF2F2F7))
|
||||
val line = colorFromHex(settings.lineColor, Color(0xFF3A3A52))
|
||||
|
||||
val surface = lerp(bg, Color.White, 0.10f)
|
||||
val surfaceVariant = lerp(bg, Color.White, 0.17f)
|
||||
val container = lerp(primary, Color.Black, 0.55f)
|
||||
val onContainer = lerp(primary, Color.White, 0.75f)
|
||||
|
||||
val container = Color(0xFF14532D)
|
||||
val onContainer = Color(0xFFB7F0C6)
|
||||
val colors = darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = primary.contrastingTextColor(),
|
||||
primaryContainer = container,
|
||||
onPrimaryContainer = onContainer,
|
||||
secondary = primary,
|
||||
onSecondary = primary.contrastingTextColor(),
|
||||
secondary = accent,
|
||||
onSecondary = accent.contrastingTextColor(),
|
||||
secondaryContainer = container,
|
||||
onSecondaryContainer = onContainer,
|
||||
tertiary = primary,
|
||||
onTertiary = primary.contrastingTextColor(),
|
||||
tertiary = accent,
|
||||
onTertiary = accent.contrastingTextColor(),
|
||||
tertiaryContainer = container,
|
||||
onTertiaryContainer = onContainer,
|
||||
surfaceTint = primary,
|
||||
background = Color(0xFF000000),
|
||||
onBackground = Color(0xFFF2F2F7),
|
||||
surface = Color(0xFF1C1C1E),
|
||||
onSurface = Color(0xFFF2F2F7),
|
||||
surfaceVariant = Color(0xFF2C2C2E),
|
||||
onSurfaceVariant = Color(0xFFBEBEC4),
|
||||
outline = Color(0xFF3A3A3C),
|
||||
background = bg,
|
||||
onBackground = onBg,
|
||||
surface = surface,
|
||||
onSurface = onBg,
|
||||
surfaceVariant = surfaceVariant,
|
||||
onSurfaceVariant = onBg.copy(alpha = 0.7f),
|
||||
outline = line,
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
|
||||
5
fastlane/metadata/android/de-DE/changelogs/2.txt
Normal file
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
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
BIN
fastlane/metadata/android/de-DE/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
1
fastlane/metadata/android/de-DE/short_description.txt
Normal file
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
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
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
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
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
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
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Calendarr
|
||||
30
fdroid/com.scarriffle.calendarr.yml
Normal file
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:
|
||||
- Calendar & Agenda
|
||||
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
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
|
||||
Reference in New Issue
Block a user