Compare commits

...

16 Commits

Author SHA1 Message Date
Guido Schmit
aeb50c593c chore: F-Droid recipe category -> Calendar & Agenda (valid category) 2026-07-16 20:07:09 +02:00
Guido Schmit
52dd6e7ff0 docs: F-Droid serving via existing reverse proxy (no nginx/TLS on the LXC) 2026-07-16 19:13:15 +02:00
Guido Schmit
42187b8254 docs: F-Droid own-repo hosting on a Proxmox LXC (build on Mac, index+serve on LXC) 2026-07-16 19:09:31 +02:00
Guido Schmit
dd347a07f1 chore: GPLv3 license + F-Droid packaging (metadata, recipe, guide)
- LICENSE: GNU GPLv3
- fastlane/metadata (de-DE + en-US): title, descriptions, changelog, icon —
  used by both the own F-Droid repo and the official listing
- fdroid/com.scarriffle.calendarr.yml: ready-to-submit fdroiddata build recipe
- FDROID.md: how to run the own repo (fdroid.scarriffle.com) and submit to
  official F-Droid, incl. the signing-key caveat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:03:04 +02:00
Guido Schmit
cc51ede8a5 fix: week/day all-day strip spans multi-day events as continuous bars
Lay out all-day events as bars across their start→end day columns, packed into
lanes, instead of listing a multi-day event separately in each day's column
(mirrors the month view and iOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:20:21 +02:00
Guido Schmit
101370719b Android: full-screen menu landing page + much denser settings
- gear now opens a full-screen Menu screen (Profile / Appearance / Accounts /
  Groups / Sync / Switch server / Logout), replacing the bottom-sheet menu that
  sat partly under the system nav buttons. Each entry opens its own full-screen
  page and returns to the menu on close.
- settings 'Darstellung' is the appearance table only again (no long scroll to
  reach navigation)
- much more compact settings: dropdowns use a small outlined chip instead of a
  full-height OutlinedTextField, smaller sync icons, bodyMedium row labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:31:21 +02:00
Guido Schmit
e72ffab4f4 Android drawer/settings feedback: hamburger left, denser UI, full-screen settings
- move the top-bar hamburger to the LEFT (drawer opens from the left)
- much denser drawer: compact header, and calendar rows now use a small eye
  toggle instead of the bulky Switch, with tighter padding/text
- tapping the gear now opens the full-screen Settings directly (no bottom-sheet
  menu that sat under the system nav buttons, no redundant sync — that's in the
  drawer header). Accounts / Groups / Profile / Switch server / Logout moved
  into a 'More' section at the bottom of Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:02:08 +02:00
Guido Schmit
3212fe2c6a feat: import birthdays from Android Contacts
Mirror the iOS Contacts birthday importer: read the address book's birthday
events and reconcile them into the user's birthday calendar, scoped to this
device via external_uid 'contact:<deviceId>:<contactId>' (add/update/delete),
then report the device.

- READ_CONTACTS permission + ContactsReader (Event TYPE_BIRTHDAY, handles
  year/no-year date formats)
- API: GET /birthdays, POST /birthdays/sync-report; local-event create/update
  now carry external_uid (+ update carries rrule/birth_year)
- CalendarViewModel.syncContactBirthdays reconciles like iOS; device-local
  enable flag + stable device id in SettingsStore
- birthday dialog gains an 'Import from contacts' action (requests permission);
  auto-syncs on launch when enabled

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:48:24 +02:00
Guido Schmit
e042f92ec6 chore: bump version to 1.1.0 (versionCode 2) for Play Store testing 2026-07-15 18:38:06 +02:00
Guido Schmit
749eccfaaa feat: left navigation drawer (calendars + groups + view + menu)
Mirror the iOS side drawer on Android with a ModalNavigationDrawer opened by the
hamburger or an edge-swipe (replaces the calendar filter bottom sheet):
- header with avatar/name/server + manual sync, full menu (gear), and close
- view + group switcher chips
- flat calendar list: tap toggles visibility, long-press opens a context menu
  (hide/banish + reminders), and a Sort toggle reveals up/down reordering.
  Order is device-local (SettingsStore.calendarOrder, mirrors the web).
- month-switching is now a named dropdown (scroll vs pages) like web/iOS
- new device-local 'Hide menu button' setting (drawer opens via edge-swipe)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:35:03 +02:00
Guido Schmit
fd804000c5 feat: per-setting sync table with dynamic 8-colour theme
Redesign the settings screen into uniform rows (sync icon | name | value):
dropdowns replace the chip pickers, every colour gets a reset to a canonical
default, a per-row sync icon toggles that setting's cross-device sync, and a
global switch flips all. Enabling a flag adopts this device's current value.

- AppSettings/SettingsStore: add text/bg/line colours, cache_months,
  month_view_paged, and the account-wide sync_flags map (+ DEFAULT_SYNC,
  effective-merge on pull)
- CalendarRepository.updateSettings: partial PUT of only the synced values plus
  the flag map (unsynced columns stay untouched server-side)
- Theme: fully dynamic — primary/accent tint, background/text/line drive
  background/onBackground/outline (matching web); today/divider/label unchanged
- add hide-profile (directory_hidden) to the profile block (parity with web/iOS)
- language + text/line contrast stay device-local

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:09:46 +02:00
Guido Schmit
fdad9c9ba4 chore(android): target SDK 35 + version 1.0.0 for Play release
Play requires targeting a recent API level for new apps; bump compileSdk/
targetSdk to 35 and set the release version name to 1.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:30:27 +02:00
Guido Schmit
1f90503104 chore(android): git-ignore keystore.properties + add example
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:16:42 +02:00
Guido Schmit
486cc9c622 feat(android): single birthday-calendar model + group-visible exclusion
- one birthday calendar per user: BirthdayDialog targets the single calendar and
  offers activation when none exists; VM birthdayCalendar()/ensureBirthdayCalendar()
- removed the birthday toggle from the generic new-calendar dialog
- exclude the birthday calendar from the group-visible ("shared calendar") picker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:08:19 +02:00
Guido Schmit
7dbaad4b00 feat(android): birthday display, calendar creation and manual entry
- CalEvent parses is_birthday and exposes renderTitle (server age)
- EventLabel renders a cake icon + render title in month/week/day/agenda/preview
- LocalCalendar model + repository/api gain is_birthday, notify-days, rrule,
  birth_year; AccountsScreen can create a birthday calendar with a notify picker
- FAB long-press menu -> "New birthday" opens a minimal name+date dialog
  (year-unknown option) targeting a birthday calendar
- L10n strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:39:33 +02:00
Guido Schmit
5b5a5d5cd8 chore(android): release signing via keystore.properties
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:39:33 +02:00
43 changed files with 2481 additions and 427 deletions

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ captures/
# Keystore files # Keystore files
*.jks *.jks
*.keystore *.keystore
keystore.properties
# macOS # macOS
.DS_Store .DS_Store

96
FDROID.md Normal file
View 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
View 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>.

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
id("com.android.application") version "8.10.0" id("com.android.application") version "8.10.0"
id("org.jetbrains.kotlin.android") version "1.9.20" id("org.jetbrains.kotlin.android") version "1.9.20"
@@ -5,25 +8,47 @@ plugins {
id("com.google.dagger.hilt.android") version "2.49" id("com.google.dagger.hilt.android") version "2.49"
} }
// Release signing is driven by a git-ignored keystore.properties in the repo
// root (see keystore.properties.example). Absent (e.g. CI / fresh clone) the
// release build simply stays unsigned — debug builds are unaffected.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
}
android { android {
namespace = "com.scarriffle.calendarr" namespace = "com.scarriffle.calendarr"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.scarriffle.calendarr" applicationId = "com.scarriffle.calendarr"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 35
versionCode = 1 versionCode = 2
versionName = "0.1.0" versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (keystorePropsFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application <application
android:name=".CalendarrApplication" android:name=".CalendarrApplication"

View File

@@ -118,27 +118,31 @@ class CalendarRepository @Inject constructor(
suspend fun getSettings(): AppSettings = guarded { api.getSettings() } suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
suspend fun updateSettings(s: AppSettings) = guarded { /** Push only the values whose sync flag is on (partial update; the server
api.updateSettings( * leaves unsynced columns untouched), plus the account-wide flag map. */
jsonBody( suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
"default_view" to s.defaultView, val body = mutableMapOf<String, Any?>()
"week_start_day" to s.weekStartDay, fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
"primary_color" to s.primaryColor, addIf("default_view", s.defaultView)
"accent_color" to s.accentColor, addIf("week_start_day", s.weekStartDay)
"today_color" to s.todayColor, addIf("dim_past_events", s.dimPastEvents)
"dim_past_events" to s.dimPastEvents, addIf("hour_height", s.hourHeight)
"text_contrast" to s.textContrast, addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
"line_contrast" to s.lineContrast, // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
"hour_height" to s.hourHeight, addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
"language" to s.language, addIf("primary_color", s.primaryColor)
"month_divider_color" to s.monthDividerColor, addIf("accent_color", s.accentColor)
"month_label_color" to s.monthLabelColor, addIf("today_color", s.todayColor)
"private_event_visibility" to s.privateEventVisibility, addIf("text_color", s.textColor)
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. addIf("bg_color", s.backgroundColor)
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL), addIf("line_color", s.lineColor)
"default_event_duration_minutes" to s.defaultEventDurationMinutes, addIf("month_divider_color", s.monthDividerColor)
) addIf("month_label_color", s.monthLabelColor)
).ensureSuccess() addIf("cache_months", s.cacheMonths)
addIf("month_view_paged", s.monthViewPaged)
// The flag map is always account-wide; send it every push.
body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>)
api.updateSettings(jsonBody(body)).ensureSuccess()
} }
suspend fun getProfile(): UserProfile = guarded { api.getProfile() } suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
@@ -186,8 +190,17 @@ class CalendarRepository @Inject constructor(
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() } suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
suspend fun addLocalCalendar(name: String, color: String) = suspend fun addLocalCalendar(
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) } name: String, color: String,
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
) = guarded {
api.addLocalCalendar(jsonBody(buildMap {
put("name", name)
put("color", color)
if (isBirthday) put("is_birthday", true)
if (birthdayNotifyDaysBefore != null) put("birthday_notify_days_before", birthdayNotifyDaysBefore)
}))
}
suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() } suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() }
@@ -342,8 +355,9 @@ class CalendarRepository @Inject constructor(
calendarId: Int, title: String, start: Instant, end: Instant, calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded { ) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)) api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess() .ensureSuccess()
} }
@@ -351,8 +365,9 @@ class CalendarRepository @Inject constructor(
uid: String, title: String, start: Instant, end: Instant, uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded { ) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders)) api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess() .ensureSuccess()
} }
@@ -489,8 +504,13 @@ class CalendarRepository @Inject constructor(
// ---- Profile & targeted settings ---- // ---- Profile & targeted settings ----
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded { suspend fun updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Boolean? = null): String? = guarded {
val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email)) val resp = api.updateProfile(jsonBody(
"display_name" to displayName,
"username" to username,
"email" to email,
"directory_hidden" to directoryHidden,
))
resp.ensureSuccess() resp.ensureSuccess()
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull() runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
} }
@@ -599,6 +619,7 @@ class CalendarRepository @Inject constructor(
calendarId: Int?, title: String, start: Instant, end: Instant, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = jsonBody( ) = jsonBody(
buildMap { buildMap {
calendarId?.let { put("calendar_id", it) } calendarId?.let { put("calendar_id", it) }
@@ -611,6 +632,19 @@ class CalendarRepository @Inject constructor(
if (!color.isNullOrBlank()) put("color", color) if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate) put("private", isPrivate)
if (reminders != null) put("reminders", org.json.JSONArray(reminders)) if (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()
}
} }

View File

@@ -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
}
}

View File

@@ -34,8 +34,13 @@ class SettingsStore @Inject constructor(
language = prefs.getString(K_LANGUAGE, null) ?: "de", language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF",
backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000",
lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52",
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 }, defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60), defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60),
cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3),
monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false),
) )
fun saveSettings(s: AppSettings) { fun saveSettings(s: AppSettings) {
@@ -52,11 +57,97 @@ class SettingsStore @Inject constructor(
.putString(K_LANGUAGE, s.language) .putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor) .putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor) .putString(K_LABEL, s.monthLabelColor)
.putString(K_TEXT_COLOR, s.textColor)
.putString(K_BG_COLOR, s.backgroundColor)
.putString(K_LINE_COLOR, s.lineColor)
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1) .putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes) .putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
.putInt(K_CACHE_MONTHS, s.cacheMonths)
.putBoolean(K_MONTH_PAGED, s.monthViewPaged)
.apply() .apply()
} }
// --- Per-setting cross-device sync flags (account-wide; server is authority) ---
/** Keys this Android client can sync. Excludes language (device "system" has
* no server value) and text/line contrast (iOS-style opacity, device-local). */
val syncableKeys: List<String> = listOf(
"default_view", "week_start_day", "dim_past_events", "hour_height",
"default_event_duration_minutes", "default_reminder_minutes",
"primary_color", "accent_color", "today_color",
"text_color", "bg_color", "line_color",
"month_divider_color", "month_label_color",
"cache_months", "month_view_paged",
)
private val defaultSync: Map<String, Boolean> = syncableKeys.associateWith { key ->
key != "cache_months" && key != "month_view_paged"
}
fun loadSyncFlags(): Map<String, Boolean> {
val result = defaultSync.toMutableMap()
val raw = prefs.getString(K_SYNC_FLAGS, null)
if (!raw.isNullOrBlank()) {
runCatching { org.json.JSONObject(raw) }.getOrNull()?.let { obj ->
for (key in syncableKeys) if (obj.has(key)) result[key] = obj.getBoolean(key)
}
}
return result
}
private fun writeSyncFlags(f: Map<String, Boolean>) {
prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply()
}
fun storeServerFlags(server: Map<String, Boolean>?) {
if (server == null) return
val f = loadSyncFlags().toMutableMap()
for (key in syncableKeys) server[key]?.let { f[key] = it }
writeSyncFlags(f)
}
fun setSyncFlag(key: String, on: Boolean) {
val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f)
}
fun setAllSyncFlags(on: Boolean) {
val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f)
}
/** Merge a server snapshot with local values honouring the (refreshed) flags:
* synced keys take the server value, others keep the local value. Persists
* the result and returns the effective settings. */
fun applyServerPull(server: AppSettings): AppSettings {
storeServerFlags(server.syncFlags)
val flags = loadSyncFlags()
val local = loadSettings()
fun on(key: String) = flags[key] == true
val eff = local.copy(
defaultView = if (on("default_view")) server.defaultView else local.defaultView,
weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay,
dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents,
hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight,
defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes,
defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes,
primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor,
accentColor = if (on("accent_color")) server.accentColor else local.accentColor,
todayColor = if (on("today_color")) server.todayColor else local.todayColor,
textColor = if (on("text_color")) server.textColor else local.textColor,
backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor,
lineColor = if (on("line_color")) server.lineColor else local.lineColor,
monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor,
monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor,
cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths,
monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged,
// Device-local (never synced): language + contrast levels.
language = local.language,
textContrast = local.textContrast,
lineContrast = local.lineContrast,
)
saveSettings(eff)
return eff
}
/** Device-local cache range in months around today (default 3). */ /** Device-local cache range in months around today (default 3). */
var cacheMonths: Int var cacheMonths: Int
get() = prefs.getInt(K_CACHE_MONTHS, 3) get() = prefs.getInt(K_CACHE_MONTHS, 3)
@@ -68,6 +159,25 @@ class SettingsStore @Inject constructor(
get() = prefs.getBoolean(K_MONTH_PAGED, false) get() = prefs.getBoolean(K_MONTH_PAGED, false)
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply() set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = prefs.getBoolean(K_HIDE_MENU, false)
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
/** Whether this device mirrors its Contacts birthdays into the birthday calendar. */
var birthdaysSyncEnabled: Boolean
get() = prefs.getBoolean(K_BDAY_ENABLED, false)
set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply()
/** Stable per-install id used to scope this device's contact-birthday rows. */
val birthdaysDeviceId: String
get() {
prefs.getString(K_BDAY_DEVICE, null)?.let { return it }
val id = java.util.UUID.randomUUID().toString()
prefs.edit().putString(K_BDAY_DEVICE, id).apply()
return id
}
// --- Hidden calendars ("source:id") --- // --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String> var hiddenCalendarKeys: Set<String>
@@ -89,6 +199,18 @@ class SettingsStore @Inject constructor(
get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet() get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply() set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply()
// --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) ---
var calendarOrder: List<String>
get() {
val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList()
return runCatching {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList())
}
set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply()
private companion object { private companion object {
const val K_DEFAULT_VIEW = "default_view" const val K_DEFAULT_VIEW = "default_view"
const val K_WEEK_START = "week_start_day" const val K_WEEK_START = "week_start_day"
@@ -102,12 +224,20 @@ class SettingsStore @Inject constructor(
const val K_LANGUAGE = "language" const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color" const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color" const val K_LABEL = "month_label_color"
const val K_TEXT_COLOR = "text_color"
const val K_BG_COLOR = "bg_color"
const val K_LINE_COLOR = "line_color"
const val K_SYNC_FLAGS = "sync_flags"
const val K_DEFAULT_REMINDER = "default_reminder_minutes" const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_DEFAULT_DURATION = "default_event_duration_minutes" const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months" const val K_CACHE_MONTHS = "cache_months"
const val K_MONTH_PAGED = "month_view_paged" const val K_MONTH_PAGED = "month_view_paged"
const val K_HIDE_MENU = "hide_menu_button"
const val K_BDAY_ENABLED = "birthdays_sync_enabled"
const val K_BDAY_DEVICE = "birthdays_device_id"
const val K_HIDDEN = "hidden_calendar_keys" const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys" const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys" const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
const val K_CAL_ORDER = "calendar_order"
} }
} }

View File

@@ -222,6 +222,12 @@ interface CalendarrApi {
@DELETE("api/local/events/{uid}") @DELETE("api/local/events/{uid}")
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody> suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
@GET("api/local/calendars/{id}/birthdays")
suspend fun getBirthdays(@Path("id") id: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry>
@POST("api/birthdays/sync-report")
suspend fun reportBirthdaySync(@Body body: RequestBody): Response<ResponseBody>
@POST("api/caldav/events") @POST("api/caldav/events")
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody> suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>

View File

@@ -36,6 +36,8 @@ data class LocalCalendar(
val permission: String? = null, val permission: String? = null,
val group: Boolean = false, val group: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
@Json(name = "is_birthday") val isBirthday: Boolean = false,
@Json(name = "birthday_notify_days_before") val birthdayNotifyDaysBefore: Int? = null,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
@@ -96,6 +98,19 @@ data class UserProfile(
@Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "is_admin") val isAdmin: Boolean = false,
@Json(name = "has_avatar") val hasAvatar: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false,
@Json(name = "totp_enabled") val totpEnabled: Boolean = false, @Json(name = "totp_enabled") val totpEnabled: Boolean = false,
@Json(name = "directory_hidden") val directoryHidden: Boolean = false,
)
/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced
* rows carry an external_uid ("contact:<deviceId>:<contactId>"); manual ones null. */
@JsonClass(generateAdapter = false)
data class BirthdayEntry(
val uid: String,
@Json(name = "external_uid") val externalUid: String? = null,
val title: String = "",
val month: Int? = null,
val day: Int? = null,
@Json(name = "birth_year") val birthYear: Int? = null,
) )
/** A calendar the user can create events in (resolved from all writable sources). */ /** A calendar the user can create events in (resolved from all writable sources). */

View File

@@ -22,6 +22,11 @@ data class AppSettings(
@Json(name = "language") val language: String = "de", @Json(name = "language") val language: String = "de",
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
@Json(name = "month_label_color") val monthLabelColor: String = "#7090c0", @Json(name = "month_label_color") val monthLabelColor: String = "#7090c0",
// Text / background / line colours drive the Material theme (onBackground,
// background, outline) so the whole app follows them, matching web/iOS.
@Json(name = "text_color") val textColor: String = "#FFFFFF",
@Json(name = "bg_color") val backgroundColor: String = "#000000",
@Json(name = "line_color") val lineColor: String = "#3A3A52",
// How this user's private events appear to other group members: 'hidden' | 'busy'. // How this user's private events appear to other group members: 'hidden' | 'busy'.
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
@@ -29,6 +34,11 @@ data class AppSettings(
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null, @Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
// Duration (minutes) applied to a newly created event's end time. // Duration (minutes) applied to a newly created event's end time.
@Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60, @Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60,
// Preload range in months (device-local by default) and month-view paging.
@Json(name = "cache_months") val cacheMonths: Int = 3,
@Json(name = "month_view_paged") val monthViewPaged: Boolean = false,
// Account-wide per-setting sync flags, fully resolved by the server.
@Json(name = "sync_flags") val syncFlags: Map<String, Boolean>? = null,
) { ) {
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
} }

View File

@@ -38,7 +38,16 @@ data class CalEvent(
val reminders: List<Int> = emptyList(), val reminders: List<Int> = emptyList(),
// True for events from a calendar shared with the user read-only. // True for events from a calendar shared with the user read-only.
val readOnly: Boolean = false, val readOnly: Boolean = false,
// True for events from a birthday calendar — clients show a cake icon and
// the server bakes the age into `displayTitle`.
val isBirthday: Boolean = false,
) { ) {
/**
* Title to render: the server-decorated one (birthday age, group prefix)
* wins over the raw title, which is kept for editing.
*/
val renderTitle: String
get() = displayTitle?.takeIf { it.isNotBlank() } ?: title
/** /**
* Group view supplies a server-resolved colour (display_color); otherwise * Group view supplies a server-resolved colour (display_color); otherwise
* per-event override colour, then the calendar's colour, then a stable * per-event override colour, then the calendar's colour, then a stable
@@ -129,6 +138,7 @@ data class CalEvent(
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() } (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(), } ?: emptyList(),
readOnly = json.optBoolean("read_only", false), readOnly = json.optBoolean("read_only", false),
isBirthday = json.optBoolean("is_birthday", false),
) )
} }
} }

View File

@@ -69,6 +69,8 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
onSwitchServer = vm::switchServer, onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings, onSettingsChanged = vm::applyLocalSettings,
onSettingsSynced = vm::refreshSettings, onSettingsSynced = vm::refreshSettings,
username = vm.username,
serverUrl = vm.serverUrl,
) )
} ?: SplashScreen() } ?: SplashScreen()
} }

View File

@@ -55,6 +55,25 @@ object L10n {
"settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe", "settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe", "settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel", "settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
"settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe",
"settings.color.line" to "Linienfarbe",
"settings.sync_all" to "Alle synchronisieren",
"settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen",
"settings.sync_this" to "Zwischen Geräten synchronisieren",
"settings.reset" to "Zurücksetzen",
"settings.appearance" to "Ansicht",
"settings.device" to "Nur auf diesem Gerät",
"settings.more" to "Mehr",
"settings.hide_menu_button" to "Menü-Button ausblenden",
"settings.month_mode" to "Monatswechsel",
"settings.month_mode.scroll" to "Fortlaufend scrollen",
"settings.month_mode.paged" to "Seitenweise blättern",
"settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
"settings.directory_hidden" to "Profil verbergen",
"settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.",
"settings.defaultreminder" to "Standard-Erinnerung",
"reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit",
"reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher",
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast", "settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel", "settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum", "settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
@@ -122,6 +141,8 @@ object L10n {
"accounts.ha.add" to "Home Assistant hinzufügen", "accounts.ha.add" to "Home Assistant hinzufügen",
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden", "filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.sort" to "Sortieren", "filter.done" to "Fertig",
"filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten",
"filter.button" to "Kalender ein-/ausblenden", "filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen", "filter.sync_error" to "Synchronisierung fehlgeschlagen",
"filter.banish" to "Dauerhaft ausblenden", "filter.banish" to "Dauerhaft ausblenden",
@@ -135,6 +156,20 @@ object L10n {
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", "caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
"local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe", "local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
"local.create" to "Erstellen", "local.create" to "Erstellen",
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
"birthday.calendar_name" to "Geburtstage",
"birthday.activate" to "Geburtstagskalender aktivieren",
"birthday.import_contacts" to "Aus Kontakten importieren",
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
"birthday.is_calendar" to "Geburtstagskalender",
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
"birthday.target" to "Geburtstagskalender",
"birthday.no_calendars" to "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender\".",
"birthday.notify" to "Erinnerung", "birthday.notify.off" to "Aus",
"birthday.notify.same_day" to "Am Tag", "birthday.notify.one_day" to "1 Tag vorher",
"birthday.notify.days" to "%d Tage vorher",
"birthday.created" to "Geburtstag „%s\" hinzugefügt",
"ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL", "ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL",
"ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren", "ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren",
"ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.", "ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.",
@@ -201,6 +236,25 @@ object L10n {
"settings.colors" to "Colors", "settings.color.primary" to "Primary color", "settings.colors" to "Colors", "settings.color.primary" to "Primary color",
"settings.color.accent" to "Accent color", "settings.color.today" to "Today color", "settings.color.accent" to "Accent color", "settings.color.today" to "Today color",
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation", "settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
"settings.color.text" to "Text color", "settings.color.background" to "Background color",
"settings.color.line" to "Line color",
"settings.sync_all" to "Sync all",
"settings.sync_all.desc" to "Share these settings across your devices",
"settings.sync_this" to "Sync across devices",
"settings.reset" to "Reset",
"settings.appearance" to "View",
"settings.device" to "This device only",
"settings.more" to "More",
"settings.hide_menu_button" to "Hide menu button",
"settings.month_mode" to "Month switching",
"settings.month_mode.scroll" to "Continuous scroll",
"settings.month_mode.paged" to "Page by page",
"settings.device.footer" to "These settings apply to this device only and are not synced.",
"settings.directory_hidden" to "Hide profile",
"settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.",
"settings.defaultreminder" to "Default reminder",
"reminder.off" to "Off", "reminder.at_start" to "At start time",
"reminder.1d" to "1 day before", "reminder.1w" to "1 week before",
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast", "settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium", "settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum", "settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
@@ -268,6 +322,8 @@ object L10n {
"accounts.ha.add" to "Add Home Assistant", "accounts.ha.add" to "Add Home Assistant",
"filter.title" to "Calendars", "filter.empty" to "No calendars available", "filter.title" to "Calendars", "filter.empty" to "No calendars available",
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all", "filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.sort" to "Sort", "filter.done" to "Done",
"filter.move_up" to "Move up", "filter.move_down" to "Move down",
"filter.button" to "Show/hide calendars", "filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed", "filter.sync_error" to "Sync failed",
"filter.banish" to "Hide permanently", "filter.banish" to "Hide permanently",
@@ -281,6 +337,20 @@ object L10n {
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",
"local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color", "local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
"local.create" to "Create", "local.create" to "Create",
"birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday",
"birthday.calendar_name" to "Birthdays",
"birthday.activate" to "Enable birthday calendar",
"birthday.import_contacts" to "Import from contacts",
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
"birthday.is_calendar" to "Birthday calendar",
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
"birthday.target" to "Birthday calendar",
"birthday.no_calendars" to "No birthday calendar yet. Create a calendar and enable \"Birthday calendar\" first.",
"birthday.notify" to "Reminder", "birthday.notify.off" to "Off",
"birthday.notify.same_day" to "On the day", "birthday.notify.one_day" to "1 day before",
"birthday.notify.days" to "%d days before",
"birthday.created" to "Birthday \"%s\" added",
"ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL", "ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL",
"ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe", "ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe",
"ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min", "ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min",

View File

@@ -62,12 +62,12 @@ class MainViewModel @Inject constructor(
_route.value = computeRoute() _route.value = computeRoute()
} }
/** Pull appearance settings from the server, caching them locally. */ /** Pull settings from the server and merge them with local values honouring
* each setting's sync flag (synced keys take the server value). */
fun refreshSettings() { fun refreshSettings() {
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.getSettings() }.onSuccess { s -> runCatching { repository.getSettings() }.onSuccess { s ->
settingsStore.saveSettings(s) _settings.value = settingsStore.applyServerPull(s)
_settings.value = s
} }
} }
} }

View File

@@ -61,8 +61,12 @@ class AccountsViewModel @Inject constructor(
} }
} }
fun addLocal(name: String, color: String, onChanged: () -> Unit) = fun addLocal(
mutate(onChanged) { repository.addLocalCalendar(name, color) } name: String, color: String, onChanged: () -> Unit,
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
) = mutate(onChanged) {
repository.addLocalCalendar(name, color, isBirthday, birthdayNotifyDaysBefore)
}
fun deleteLocal(id: Int, onChanged: () -> Unit) = fun deleteLocal(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteLocalCalendar(id) } mutate(onChanged) { repository.deleteLocalCalendar(id) }

View File

@@ -98,7 +98,12 @@ private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: (
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) EventLabel(
event = event,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Medium,
)
if (event.location.isNotBlank()) { if (event.location.isNotBlank()) {
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }

View File

@@ -0,0 +1,115 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.ui.tr
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Minimal "new birthday" mask for the user's single birthday calendar: name +
* date (with an optional "year unknown"). Saves an all-day, yearly-recurring
* local event; the server adds the age suffix and cake icon. If no birthday
* calendar exists yet, offers to activate one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BirthdayDialog(
calendar: LocalCalendar?,
onDismiss: () -> Unit,
onActivate: () -> Unit,
onImportContacts: () -> Unit,
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
) {
var name by remember { mutableStateOf("") }
var yearUnknown by remember { mutableStateOf(false) }
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
var showPicker by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(tr("birthday.new_title")) },
text = {
Column {
if (calendar == null) {
Text(tr("birthday.activate_hint"), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
OutlinedTextField(
name, { name = it },
label = { Text(tr("birthday.person")) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
FilterChip(
selected = true, onClick = { showPicker = true },
label = { Text("${tr("birthday.date")}: ${formatBirthday(pickedDate, yearUnknown)}") },
)
Spacer(Modifier.size(8.dp))
FilterChip(
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
label = { Text(tr("birthday.year_unknown")) },
)
Spacer(Modifier.size(4.dp))
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
}
}
},
confirmButton = {
if (calendar == null) {
TextButton(onClick = onActivate) { Text(tr("birthday.activate")) }
} else {
TextButton(
enabled = name.isNotBlank(),
onClick = { onSave(name.trim(), pickedDate, !yearUnknown) },
) { Text(tr("common.save")) }
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
)
if (showPicker) {
val dpState = rememberDatePickerState(
initialSelectedDateMillis = pickedDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
dpState.selectedDateMillis?.let { millis ->
pickedDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
}
showPicker = false
}) { Text(tr("common.save")) }
},
dismissButton = { TextButton(onClick = { showPicker = false }) { Text(tr("common.cancel")) } },
) { DatePicker(state = dpState) }
}
}
private val DAY_MONTH = DateTimeFormatter.ofPattern("dd.MM.")
private val DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("dd.MM.yyyy")
private fun formatBirthday(date: LocalDate, yearUnknown: Boolean): String =
date.format(if (yearUnknown) DAY_MONTH else DAY_MONTH_YEAR)

View File

@@ -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() },
)
}
}
}

View File

@@ -5,8 +5,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -23,15 +25,17 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Today import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -47,6 +51,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -68,23 +73,26 @@ import com.scarriffle.calendarr.ui.event.EventDetailScreen
import com.scarriffle.calendarr.ui.event.EventEditorSheet import com.scarriffle.calendarr.ui.event.EventEditorSheet
import com.scarriffle.calendarr.ui.groups.GroupIcon import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.groups.GroupsScreen 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.profile.ProfileScreen
import com.scarriffle.calendarr.ui.settings.SettingsScreen import com.scarriffle.calendarr.ui.settings.SettingsScreen
import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.ui.tr
import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS } private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun CalendarScreen( fun CalendarScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
onSwitchServer: () -> Unit, onSwitchServer: () -> Unit,
onSettingsChanged: (AppSettings) -> Unit, onSettingsChanged: (AppSettings) -> Unit,
onSettingsSynced: () -> Unit, onSettingsSynced: () -> Unit,
username: String = "",
serverUrl: String = "",
vm: CalendarViewModel = hiltViewModel(), vm: CalendarViewModel = hiltViewModel(),
) { ) {
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
@@ -122,13 +130,48 @@ fun CalendarScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
} }
// Contacts birthday import (mirrors iOS): request permission on demand; sync
// this device's contact birthdays into the birthday calendar.
val deviceName = android.os.Build.MODEL ?: "Android"
fun runBirthdayImport() {
vm.birthdaysSyncEnabled = true
vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName)
}
val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { granted -> if (granted) runBirthdayImport() }
fun startBirthdayImport() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS)
}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (vm.birthdaysSyncEnabled &&
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport()
}
var viewMenuOpen by remember { mutableStateOf(false) } var viewMenuOpen by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
var showFilter by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) } var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
var editor by remember { mutableStateOf<EditorRequest?>(null) } var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) } var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) } var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
var fabMenuOpen by remember { mutableStateOf(false) }
var showBirthday by remember { mutableStateOf(false) }
var birthdayCal by remember { mutableStateOf<com.scarriffle.calendarr.domain.model.LocalCalendar?>(null) }
val birthdayScope = rememberCoroutineScope()
val birthdayCalName = tr("birthday.calendar_name")
// Side navigation drawer (calendars + groups + view + menu).
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }
androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) {
if (drawerState.isOpen) vm.loadAllCalendars()
}
// Continuous month scrolling // Continuous month scrolling
val monthListState = rememberLazyListState() val monthListState = rememberLazyListState()
@@ -150,6 +193,18 @@ fun CalendarScreen(
if (isMonth) todaySignal++ else vm.moveToToday() if (isMonth) todaySignal++ else vm.moveToToday()
} }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawerContent(
state = state, vm = vm, username = username, serverUrl = serverUrl,
calendars = drawerCalendars,
onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU },
onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() },
onClose = { drawerScope.launch { drawerState.close() } },
)
},
) {
Scaffold( Scaffold(
topBar = { topBar = {
CompactTopBar( CompactTopBar(
@@ -157,24 +212,47 @@ fun CalendarScreen(
viewType = state.viewType, viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching, loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen, viewMenuOpen = viewMenuOpen,
onMenu = { showMenu = true }, showMenuButton = !state.hideMenuButton,
onMenu = { drawerScope.launch { drawerState.open() } },
onPrev = { goPrev() }, onPrev = { goPrev() },
onToday = { goToday() }, onToday = { goToday() },
onNext = { goNext() }, onNext = { goNext() },
onFilter = { showFilter = true },
onViewMenuToggle = { viewMenuOpen = it }, onViewMenuToggle = { viewMenuOpen = it },
onSelectView = { vm.setViewType(it) }, onSelectView = { vm.setViewType(it) },
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( // Tap = new event; long-press opens a small menu (new event / new
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, // birthday). A transparent overlay carries the combined click so the
shape = CircleShape, // long-press is reliable on the Material FAB.
containerColor = MaterialTheme.colorScheme.primary, Box(Modifier.navigationBarsPadding()) {
contentColor = MaterialTheme.colorScheme.onPrimary, FloatingActionButton(
modifier = Modifier.navigationBarsPadding(), onClick = {},
) { shape = CircleShape,
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
}
Box(
Modifier
.matchParentSize()
.clip(CircleShape)
.combinedClickable(
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
onLongClick = { fabMenuOpen = true },
),
)
DropdownMenu(expanded = fabMenuOpen, onDismissRequest = { fabMenuOpen = false }) {
DropdownMenuItem(
text = { Text(tr("cal.new_event")) },
onClick = { fabMenuOpen = false; editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
)
DropdownMenuItem(
text = { Text(tr("birthday.new")) },
onClick = { fabMenuOpen = false; showBirthday = true },
)
}
} }
}, },
// Edge-to-edge: we place the system-bar insets ourselves (top bar gets // Edge-to-edge: we place the system-bar insets ourselves (top bar gets
@@ -210,35 +288,10 @@ fun CalendarScreen(
} }
} }
} }
}
// ---- Sheets ---- // ---- 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 -> dayPreview?.let { date ->
DayPreviewDialog( DayPreviewDialog(
date = date, date = date,
@@ -251,6 +304,20 @@ fun CalendarScreen(
) )
} }
if (showBirthday) {
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
BirthdayDialog(
calendar = birthdayCal,
onDismiss = { showBirthday = false },
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
onImportContacts = { showBirthday = false; startBirthdayImport() },
onSave = { name, date, yearKnown ->
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
showBirthday = false
},
)
}
// Keep the last event during the close animation. // Keep the last event during the close animation.
var lastDetail by remember { mutableStateOf<CalEvent?>(null) } var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
detailEvent?.let { lastDetail = it } detailEvent?.let { lastDetail = it }
@@ -297,18 +364,30 @@ fun CalendarScreen(
} }
when (overlay) { 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( Overlay.SETTINGS -> SettingsScreen(
onClose = { overlay = Overlay.NONE; vm.refreshMonthViewMode() }, onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() },
onSettingsChanged = onSettingsChanged, onSettingsChanged = onSettingsChanged,
onSettingsSynced = onSettingsSynced, onSettingsSynced = onSettingsSynced,
) )
Overlay.ACCOUNTS -> AccountsScreen( Overlay.ACCOUNTS -> AccountsScreen(
onClose = { overlay = Overlay.NONE }, onClose = { overlay = Overlay.MENU },
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
) )
Overlay.GROUPS -> GroupsScreen( Overlay.GROUPS -> GroupsScreen(
onClose = { overlay = Overlay.NONE }, onClose = { overlay = Overlay.MENU },
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() }, onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) }, onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
) )
@@ -405,11 +484,11 @@ private fun CompactTopBar(
viewType: CalViewType, viewType: CalViewType,
loading: Boolean, loading: Boolean,
viewMenuOpen: Boolean, viewMenuOpen: Boolean,
showMenuButton: Boolean,
onMenu: () -> Unit, onMenu: () -> Unit,
onPrev: () -> Unit, onPrev: () -> Unit,
onToday: () -> Unit, onToday: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onFilter: () -> Unit,
onViewMenuToggle: (Boolean) -> Unit, onViewMenuToggle: (Boolean) -> Unit,
onSelectView: (CalViewType) -> Unit, onSelectView: (CalViewType) -> Unit,
) { ) {
@@ -421,6 +500,8 @@ private fun CompactTopBar(
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Menu (hamburger) on the left — the drawer opens from the left.
if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
CompactIcon(Icons.Filled.ChevronLeft, onPrev) CompactIcon(Icons.Filled.ChevronLeft, onPrev)
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) { TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
Text(tr("nav.today"), fontSize = 13.sp) Text(tr("nav.today"), fontSize = 13.sp)
@@ -442,7 +523,6 @@ private fun CompactTopBar(
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} }
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
Box { Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) { DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
@@ -455,7 +535,6 @@ private fun CompactTopBar(
} }
} }
} }
CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
} }
Divider( Divider(
thickness = 0.5.dp, thickness = 0.5.dp,

View File

@@ -9,7 +9,9 @@ import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -60,6 +62,8 @@ data class CalendarUiState(
val allCalendars: List<CalendarFilterEntry> = emptyList(), val allCalendars: List<CalendarFilterEntry> = emptyList(),
// Device-local: month view as horizontal paged (swipe) vs. scroll feed. // Device-local: month view as horizontal paged (swipe) vs. scroll feed.
val monthViewPaged: Boolean = false, 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" fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
@@ -125,12 +129,78 @@ class CalendarViewModel @Inject constructor(
banishedKeys = settingsStore.banishedCalendarKeys, banishedKeys = settingsStore.banishedCalendarKeys,
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys, reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
monthViewPaged = settingsStore.monthViewPaged, 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() { 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. */ /** Default duration (minutes) for a new event's end time. */
@@ -501,6 +571,41 @@ class CalendarViewModel @Inject constructor(
} }
} }
/** The user's single birthday calendar, or null if not activated yet. */
suspend fun birthdayCalendar(): LocalCalendar? =
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
.firstOrNull { it.isBirthday && it.owned }
/** The birthday calendar, creating the single one (named [name]) if none. */
suspend fun ensureBirthdayCalendar(name: String): LocalCalendar? {
birthdayCalendar()?.let { return it }
return runCatching {
repository.addLocalCalendar(name, "#E0407F", isBirthday = true)
}.getOrNull()
}
/**
* Create a manual birthday: an all-day, yearly-recurring local event whose
* age suffix and cake icon are added server-side. When the year is unknown
* we anchor to 1970 and send no birth_year (so no age is shown).
*/
fun createBirthday(
calendarId: Int, name: String, date: LocalDate, yearKnown: Boolean, onDone: () -> Unit,
) {
viewModelScope.launch {
val anchor = LocalDate.of(if (yearKnown) date.year else 1970, date.monthValue, date.dayOfMonth)
val start = Dates.startOfDay(anchor)
val end = Dates.startOfDay(anchor.plusDays(1))
runCatching {
repository.createLocalEvent(
calendarId, name, start, end, isAllDay = true,
location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = if (yearKnown) date.year else null,
)
}.onSuccess { loadVisible(force = true); onDone() }
}
}
/** /**
* Banish ("permanently hide") a calendar, or lift the banish. Unlike the * Banish ("permanently hide") a calendar, or lift the banish. Unlike the
* quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for

View File

@@ -154,13 +154,11 @@ private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Un
bottom = 4.dp, bottom = 4.dp,
), ),
) { ) {
Text( EventLabel(
event.title, event = event,
maxLines = 1, color = color.contrastingTextColor(),
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = color.contrastingTextColor(),
) )
} }
} }
@@ -186,10 +184,9 @@ private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit)
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).width(42.dp), modifier = Modifier.padding(start = 8.dp).width(42.dp),
) )
Text( EventLabel(
event.title, event = event,
maxLines = 1, color = MaterialTheme.colorScheme.onSurface,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View File

@@ -0,0 +1,57 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
/**
* Event label for the calendar grids: an optional leading cake icon for
* birthday events, followed by the render title (which carries the
* server-computed age). The icon inherits the text colour/size so the label
* matches whatever bar it's dropped into.
*/
@Composable
fun EventLabel(
event: CalEvent,
color: Color,
fontSize: TextUnit = 10.sp,
fontWeight: FontWeight = FontWeight.Medium,
maxLines: Int = 1,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
if (event.isBirthday) {
Icon(
Icons.Filled.Cake,
contentDescription = null,
tint = color,
modifier = Modifier
.size(fontSize.value.dp)
.padding(end = 2.dp),
)
}
Text(
event.renderTitle,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
fontSize = fontSize,
fontWeight = fontWeight,
color = color,
modifier = Modifier.weight(1f, fill = false),
)
}
}

View File

@@ -354,13 +354,11 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () ->
.padding(horizontal = 4.dp), .padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
Text( EventLabel(
bar.event.title, event = bar.event,
maxLines = 1, color = bar.textColor,
overflow = TextOverflow.Ellipsis,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = bar.textColor,
) )
} }
} }

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,6 +40,9 @@ import java.time.format.DateTimeFormatter
private val GUTTER = 48.dp private val GUTTER = 48.dp
/** An all-day event laid out as a bar spanning columns [start]..[end] in lane [lane]. */
private data class AllDayBar(val ev: CalEvent, val start: Int, val end: Int, val lane: Int)
@Composable @Composable
fun TimeGridView( fun TimeGridView(
days: List<LocalDate>, days: List<LocalDate>,
@@ -76,16 +80,48 @@ fun TimeGridView(
} }
} }
// All-day row // All-day row: continuous bars spanning an event's start→end columns and
val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } } // packed into lanes, instead of repeating a multi-day event once per day.
if (allDay.any { it.second.isNotEmpty() }) { val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } }
Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) { if (perDay.any { it.isNotEmpty() }) {
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) { // event id -> [minCol, maxCol] within this week
Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline) val range = LinkedHashMap<String, IntArray>()
val evById = HashMap<String, CalEvent>()
perDay.forEachIndexed { i, evs ->
evs.forEach { ev ->
evById[ev.id] = ev
val r = range[ev.id]
if (r == null) range[ev.id] = intArrayOf(i, i) else r[1] = i
} }
allDay.forEach { (_, evs) -> }
Column(Modifier.weight(1f).padding(horizontal = 1.dp)) { // earlier / longer first, then greedily pack into lanes
evs.forEach { ev -> AllDayChip(ev, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) } val spans = range.entries
.map { Triple(evById.getValue(it.key), it.value[0], it.value[1]) }
.sortedWith(compareBy({ it.second }, { -(it.third - it.second) }))
val laneEnd = ArrayList<Int>()
val bars = ArrayList<AllDayBar>()
for ((ev, s, e) in spans) {
var lane = 0
while (lane < laneEnd.size && laneEnd[lane] >= s) lane++
if (lane == laneEnd.size) laneEnd.add(e) else laneEnd[lane] = e
bars.add(AllDayBar(ev, s, e, lane))
}
val laneCount = (bars.maxOfOrNull { it.lane } ?: -1) + 1
Column(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
for (lane in 0 until laneCount) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
if (lane == 0) Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
}
var col = 0
bars.filter { it.lane == lane }.sortedBy { it.start }.forEach { bar ->
if (bar.start > col) { Spacer(Modifier.weight((bar.start - col).toFloat())); col = bar.start }
Box(Modifier.weight((bar.end - bar.start + 1).toFloat()).padding(horizontal = 1.dp)) {
AllDayChip(bar.ev, dimmed = dimPast && bar.ev.endDate.isBefore(now), onClick = { onEventClick(bar.ev) })
}
col = bar.end + 1
}
if (col < days.size) Spacer(Modifier.weight((days.size - col).toFloat()))
} }
} }
} }
@@ -167,13 +203,12 @@ private fun TimedEvent(
.padding(horizontal = 4.dp, vertical = 2.dp), .padding(horizontal = 4.dp, vertical = 2.dp),
) { ) {
Column { Column {
Text( EventLabel(
event.title, event = event,
fontSize = 10.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = color.contrastingTextColor(), color = color.contrastingTextColor(),
fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 2,
) )
if (height > 36.dp) { if (height > 36.dp) {
Text( Text(
@@ -212,7 +247,7 @@ private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 3.dp, vertical = 1.dp), .padding(horizontal = 3.dp, vertical = 1.dp),
) { ) {
Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor()) EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp)
} }
} }

View File

@@ -98,7 +98,7 @@ fun EventDetailScreen(
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(event.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text(event.renderTitle, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
if (event.calendarName.isNotBlank()) { if (event.calendarName.isNotBlank()) {
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }

View 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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -3,31 +3,32 @@ package com.scarriffle.calendarr.ui.settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -36,6 +37,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -56,8 +58,11 @@ import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.toHex import com.scarriffle.calendarr.util.toHex
private val PALETTE = listOf( // Canonical default colours (single source for the reset buttons).
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", private val DEFAULT_COLORS = mapOf(
"primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4",
"text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52",
"month_divider_color" to "#7090C0", "month_label_color" to "#7090C0",
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -70,8 +75,6 @@ fun SettingsScreen(
) { ) {
val initialSettings = LocalAppSettings.current val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) } var settings by remember { mutableStateOf(initialSettings) }
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
var monthPaged by remember { mutableStateOf(vm.monthViewPaged) }
fun update(newSettings: AppSettings) { fun update(newSettings: AppSettings) {
settings = newSettings settings = newSettings
@@ -79,6 +82,10 @@ fun SettingsScreen(
vm.apply(newSettings, onSettingsSynced) vm.apply(newSettings, onSettingsSynced)
} }
// Per-row sync toggle: enabling adopts this device's current value.
fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) }
fun synced(key: String) = vm.syncFlags[key] == true
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -92,117 +99,254 @@ fun SettingsScreen(
) { padding -> ) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
// 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) 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)) Divider(Modifier.padding(vertical = 16.dp))
// ---- Termine (synced) ----
Section(tr("settings.calview")) Section(tr("settings.calview"))
ChipRow( SyncDropdownRow(
options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, tr("settings.default_duration"),
selected = settings.defaultView, 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"),
onSelect = { update(settings.copy(defaultView = it)) }, settings.defaultEventDurationMinutes.toString(),
{ update(settings.copy(defaultEventDurationMinutes = it.toInt())) },
synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") },
) )
Spacer(Modifier.size(16.dp)) SyncDropdownRow(
tr("settings.defaultreminder"),
Section(tr("settings.firstweekday")) reminderOptions(),
ChipRow( (settings.defaultReminderMinutes ?: -1).toString(),
options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), { update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) },
selected = settings.weekStartDay, synced("default_reminder_minutes"), { toggle("default_reminder_minutes") },
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)) },
) )
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
// ---- Ansicht (synced) ----
Section(tr("settings.appearance"))
SyncDropdownRow(
tr("settings.defaultview"),
CalViewType.entries.map { it.key to tr("view.${it.key}") },
settings.defaultView,
{ update(settings.copy(defaultView = it)) },
synced("default_view"), { toggle("default_view") },
)
SyncDropdownRow(
tr("settings.firstweekday"),
listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
settings.weekStartDay,
{ update(settings.copy(weekStartDay = it)) },
synced("week_start_day"), { toggle("week_start_day") },
)
SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") })
SyncDropdownRow(
tr("settings.month_mode"),
listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")),
settings.monthViewPaged.toString(),
{ update(settings.copy(monthViewPaged = it.toBoolean())) },
synced("month_view_paged"), { toggle("month_view_paged") },
)
SyncDropdownRow(
tr("settings.hourheight"),
listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")),
settings.hourHeight.toString(),
{ update(settings.copy(hourHeight = it.toInt())) },
synced("hour_height"), { toggle("hour_height") },
)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Farben (synced) ----
Section(tr("settings.colors")) Section(tr("settings.colors"))
ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) } SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) } SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) }
SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) }
SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) }
SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) }
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.hourheight")) // ---- Cache (synced) ----
ChipRow( SyncDropdownRow(
options = listOf( tr("settings.cache.range"),
"28" to tr("settings.hourheight.compact"), listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
"44" to tr("settings.hourheight.normal"), settings.cacheMonths.toString(),
"60" to tr("settings.hourheight.comfort"), { update(settings.copy(cacheMonths = it.toInt())) },
"80" to tr("settings.hourheight.large"), synced("cache_months"), { toggle("cache_months") },
),
selected = settings.hourHeight.toString(),
onSelect = { update(settings.copy(hourHeight = it.toInt())) },
)
Spacer(Modifier.size(16.dp))
Section(tr("settings.textcontrast"))
ChipRow(
options = listOf(
"1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"),
"3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"),
),
selected = settings.textContrast.toString(),
onSelect = { update(settings.copy(textContrast = it.toInt())) },
)
Spacer(Modifier.size(16.dp))
Section(tr("settings.linecontrast"))
ChipRow(
options = listOf(
"1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"),
"3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"),
),
selected = settings.lineContrast.toString(),
onSelect = { update(settings.copy(lineContrast = it.toInt())) },
) )
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.cache.title")) // ---- Gerät (device-local: language + contrast) ----
ChipRow( Section(tr("settings.device"))
options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")), DeviceDropdownRow(
selected = cacheMonths.toString(), tr("settings.language"),
onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() }, listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
) settings.language,
) { update(settings.copy(language = it)) }
DeviceDropdownRow(
tr("settings.textcontrast"),
listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")),
settings.textContrast.toString(),
) { update(settings.copy(textContrast = it.toInt())) }
DeviceDropdownRow(
tr("settings.linecontrast"),
listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")),
settings.lineContrast.toString(),
) { update(settings.copy(lineContrast = it.toInt())) }
var hideMenu by remember { mutableStateOf(vm.hideMenuButton) }
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it })
}
Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp))
Spacer(Modifier.size(40.dp)) Spacer(Modifier.size(40.dp))
} }
} }
} }
} }
@Composable
private fun reminderOptions(): List<Pair<String, String>> = listOf(
"-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min",
"30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"),
)
@Composable @Composable
private fun Section(title: String) { private fun Section(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
} }
/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */
/** Leading per-row sync toggle: highlighted = synced across devices. */
@Composable
private fun SyncIcon(on: Boolean, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Filled.Refresh,
contentDescription = tr("settings.sync_this"),
modifier = Modifier.size(18.dp),
tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Compact dropdown trigger (a small outlined chip) — much shorter than a full
// OutlinedTextField, so the settings rows aren't inflated.
@Composable
private fun Dropdown(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected
Box {
Row(
Modifier
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.clickable { expanded = true }
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(selectedLabel, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (key, label) ->
DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false })
}
}
}
}
@Composable
private fun SyncDropdownRow(
label: String,
options: List<Pair<String, String>>,
selected: String,
onSelect: (String) -> Unit,
synced: Boolean,
onToggleSync: () -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Dropdown(options, selected, onSelect)
}
}
@Composable
private fun DeviceDropdownRow(
label: String,
options: List<Pair<String, String>>,
selected: String,
onSelect: (String) -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Dropdown(options, selected, onSelect)
}
}
@Composable
private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun SyncColorRow(
syncKey: String,
label: String,
current: String,
synced: Boolean,
onToggleSync: () -> Unit,
onPick: (String) -> Unit,
) {
var showPicker by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(12.dp))
Box(
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current))
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.clickable { showPicker = true },
)
TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall)
}
}
if (showPicker) {
ColorPickerDialog(
initial = current,
title = label,
onDismiss = { showPicker = false },
onConfirm = { showPicker = false; onPick(it) },
)
}
}
/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ProfileChapter(vm: SettingsViewModel) { private fun ProfileChapter(vm: SettingsViewModel) {
val savedLabel = tr("settings.saved") val savedLabel = tr("settings.saved")
@@ -229,6 +373,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge)
Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange)
}
Spacer(Modifier.size(8.dp))
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) } Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
vm.profileMessage?.let { vm.profileMessage?.let {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
@@ -237,11 +389,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.privacy")) Section(tr("settings.privacy"))
ChipRow( Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")), Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
selected = vm.privateVisibility, Dropdown(
onSelect = vm::changePrivateVisibility, listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
) vm.privateVisibility,
vm::changePrivateVisibility,
)
}
Spacer(Modifier.size(6.dp)) Spacer(Modifier.size(6.dp))
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
@@ -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) },
)
}
}

View File

@@ -19,23 +19,40 @@ class SettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
) : ViewModel() { ) : ViewModel() {
/** Persist locally immediately, then sync to the server in the background. */ /** Persist locally immediately, then push the synced values to the server. */
fun apply(settings: AppSettings, onSynced: () -> Unit) { fun apply(settings: AppSettings, onSynced: () -> Unit) {
settingsStore.saveSettings(settings) settingsStore.saveSettings(settings)
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.updateSettings(settings) } runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
onSynced() onSynced()
} }
} }
var cacheMonths: Int /** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
get() = settingsStore.cacheMonths var hideMenuButton: Boolean
set(value) { settingsStore.cacheMonths = value } get() = settingsStore.hideMenuButton
set(value) { settingsStore.hideMenuButton = value }
/** Device-local: month view as horizontal paged (swipe) vs. scroll feed. */ // ---- Per-setting sync flags ----
var monthViewPaged: Boolean
get() = settingsStore.monthViewPaged val syncableKeys: List<String> get() = settingsStore.syncableKeys
set(value) { settingsStore.monthViewPaged = value } var syncFlags by mutableStateOf(settingsStore.loadSyncFlags())
private set
/** Toggle one setting's flag. Enabling adopts this device's current value
* (pushes the synced values up); disabling keeps the value local. */
fun toggleSync(key: String, current: AppSettings, onSynced: () -> Unit) {
settingsStore.setSyncFlag(key, !(syncFlags[key] ?: false))
syncFlags = settingsStore.loadSyncFlags()
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
}
/** Global "sync everything" switch. */
fun setAllSync(on: Boolean, current: AppSettings, onSynced: () -> Unit) {
settingsStore.setAllSyncFlags(on)
syncFlags = settingsStore.loadSyncFlags()
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
}
// ---- Profile chapter (server-backed) ---- // ---- Profile chapter (server-backed) ----
@@ -43,6 +60,8 @@ class SettingsViewModel @Inject constructor(
var loginName by mutableStateOf("") var loginName by mutableStateOf("")
var email by mutableStateOf("") var email by mutableStateOf("")
private set private set
var directoryHidden by mutableStateOf(false)
private set
var privateVisibility by mutableStateOf("busy") var privateVisibility by mutableStateOf("busy")
private set private set
var groupVisibleId by mutableStateOf(0) // 0 = none var groupVisibleId by mutableStateOf(0) // 0 = none
@@ -56,6 +75,7 @@ class SettingsViewModel @Inject constructor(
fun onDisplayNameChange(v: String) { displayName = v } fun onDisplayNameChange(v: String) { displayName = v }
fun onEmailChange(v: String) { email = v } fun onEmailChange(v: String) { email = v }
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
private fun loadProfile() { private fun loadProfile() {
viewModelScope.launch { viewModelScope.launch {
@@ -63,13 +83,16 @@ class SettingsViewModel @Inject constructor(
displayName = p.displayName ?: p.username displayName = p.displayName ?: p.username
loginName = p.username loginName = p.username
email = p.email ?: "" email = p.email ?: ""
directoryHidden = p.directoryHidden
} }
runCatching { repository.getSettings() }.onSuccess { s -> runCatching { repository.getSettings() }.onSuccess { s ->
privateVisibility = s.privateEventVisibility privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?: 0 groupVisibleId = s.groupVisibleCalendarId ?: 0
} }
runCatching { repository.getLocalCalendars() }.onSuccess { cals -> runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
ownLocalCalendars = cals.filter { it.owned && !it.group } // A birthday calendar may be shared directly, but never stand in
// as the group-visible personal calendar.
ownLocalCalendars = cals.filter { it.owned && !it.group && !it.isBirthday }
} }
} }
} }
@@ -81,6 +104,7 @@ class SettingsViewModel @Inject constructor(
displayName = displayName.trim().ifEmpty { null }, displayName = displayName.trim().ifEmpty { null },
username = null, username = null,
email = email.trim(), email = email.trim(),
directoryHidden = directoryHidden,
) )
} }
.onSuccess { profileMessage = savedLabel } .onSuccess { profileMessage = savedLabel }

View File

@@ -7,50 +7,60 @@ import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor import com.scarriffle.calendarr.util.contrastingTextColor
/** /** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
* The Calendarr brand accent — the green from the iOS `AccentColor` asset * primary colour is unset. */
* (#20A050). This drives the global control tint (buttons, FAB, switches,
* top bar) regardless of the server's per-calendar colours, matching iOS
* where the app tint is fixed and `primary_color` only styles calendar
* elements (e.g. the "today" highlight, read from [AppSettings]).
*/
val BrandGreen = Color(0xFF20A050) val BrandGreen = Color(0xFF20A050)
/**
* Fully dynamic theme: every colour is derived from [AppSettings] so the whole
* app follows the user's palette (matching the web client). primary/accent tint
* the controls; background/text/line drive `background`/`onBackground`/`outline`,
* which the calendar views already read (grid, secondary text). "today", divider
* and label colours are read directly in the views.
*/
@Composable @Composable
fun CalendarrTheme( fun CalendarrTheme(
settings: AppSettings = AppSettings(), settings: AppSettings = AppSettings(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val primary = BrandGreen val primary = colorFromHex(settings.primaryColor, BrandGreen)
val accent = colorFromHex(settings.accentColor, primary)
val bg = colorFromHex(settings.backgroundColor, Color(0xFF000000))
val onBg = colorFromHex(settings.textColor, Color(0xFFF2F2F7))
val line = colorFromHex(settings.lineColor, Color(0xFF3A3A52))
val surface = lerp(bg, Color.White, 0.10f)
val surfaceVariant = lerp(bg, Color.White, 0.17f)
val container = lerp(primary, Color.Black, 0.55f)
val onContainer = lerp(primary, Color.White, 0.75f)
val container = Color(0xFF14532D)
val onContainer = Color(0xFFB7F0C6)
val colors = darkColorScheme( val colors = darkColorScheme(
primary = primary, primary = primary,
onPrimary = primary.contrastingTextColor(), onPrimary = primary.contrastingTextColor(),
primaryContainer = container, primaryContainer = container,
onPrimaryContainer = onContainer, onPrimaryContainer = onContainer,
secondary = primary, secondary = accent,
onSecondary = primary.contrastingTextColor(), onSecondary = accent.contrastingTextColor(),
secondaryContainer = container, secondaryContainer = container,
onSecondaryContainer = onContainer, onSecondaryContainer = onContainer,
tertiary = primary, tertiary = accent,
onTertiary = primary.contrastingTextColor(), onTertiary = accent.contrastingTextColor(),
tertiaryContainer = container, tertiaryContainer = container,
onTertiaryContainer = onContainer, onTertiaryContainer = onContainer,
surfaceTint = primary, surfaceTint = primary,
background = Color(0xFF000000), background = bg,
onBackground = Color(0xFFF2F2F7), onBackground = onBg,
surface = Color(0xFF1C1C1E), surface = surface,
onSurface = Color(0xFFF2F2F7), onSurface = onBg,
surfaceVariant = Color(0xFF2C2C2E), surfaceVariant = surfaceVariant,
onSurfaceVariant = Color(0xFFBEBEC4), onSurfaceVariant = onBg.copy(alpha = 0.7f),
outline = Color(0xFF3A3A3C), outline = line,
) )
MaterialTheme( MaterialTheme(

View 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

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
Kalender-App für deinen selbst gehosteten Calendarr-Server

View File

@@ -0,0 +1 @@
Calendarr

View 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

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
Calendar client for your self-hosted Calendarr server

View File

@@ -0,0 +1 @@
Calendarr

View 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

View 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