diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..aa2ed15 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,47 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc.) +local.properties + +# Log files +*.log + +# Android Studio / IntelliJ +*.iml +.idea/ +*.ipr +*.iws +captures/ +.externalNativeBuild/ +.cxx/ + +# Keystore files +*.jks +*.keystore +keystore.properties + +# macOS +.DS_Store + +# Kotlin +.kotlin/ diff --git a/android/FDROID.md b/android/FDROID.md new file mode 100644 index 0000000..b61d003 --- /dev/null +++ b/android/FDROID.md @@ -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 . +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` 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. diff --git a/android/LICENSE b/android/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/android/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..5ea04f1 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,130 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") version "8.10.0" + id("org.jetbrains.kotlin.android") version "1.9.20" + id("org.jetbrains.kotlin.kapt") version "1.9.20" + id("com.google.dagger.hilt.android") version "2.49" +} + +// Release signing is driven by a git-ignored keystore.properties in the repo +// root (see keystore.properties.example). Absent (e.g. CI / fresh clone) the +// release build simply stays unsigned — debug builds are unaffected. +val keystorePropsFile = rootProject.file("keystore.properties") +val keystoreProps = Properties().apply { + if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) } +} + +android { + namespace = "com.scarriffle.calendarr" + compileSdk = 35 + + defaultConfig { + applicationId = "com.scarriffle.calendarr" + minSdk = 24 + targetSdk = 35 + versionCode = 2 + versionName = "1.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + + // AppAuth merges its RedirectUriReceiverActivity into the manifest and + // needs the custom scheme for the OIDC redirect. Reverse-DNS on the + // application id, per RFC 8252; the same value must be registered as a + // redirect URI in the identity provider: + // com.scarriffle.calendarr:/oauth2redirect + manifestPlaceholders["appAuthRedirectScheme"] = "com.scarriffle.calendarr" + } + + signingConfigs { + if (keystorePropsFile.exists()) { + create("release") { + storeFile = file(keystoreProps.getProperty("storeFile")) + storePassword = keystoreProps.getProperty("storePassword") + keyAlias = keystoreProps.getProperty("keyAlias") + keyPassword = keystoreProps.getProperty("keyPassword") + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + if (keystorePropsFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.5" + } + + packaging { + resources { + excludes += setOf("/META-INF/{AL2.0,LGPL2.1}") + } + } +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.core:core-splashscreen:1.0.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.activity:activity-compose:1.9.0") + + // Compose (managed by BOM) + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material3:material3-window-size-class") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + // DI + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + implementation("com.google.dagger:hilt-android:2.49") + kapt("com.google.dagger:hilt-compiler:2.49") + + // Networking + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-moshi:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("com.squareup.moshi:moshi-kotlin:1.15.0") + + // Secure credential storage + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // OIDC / SSO — Authorization Code Flow with PKCE in a Custom Tab + implementation("net.openid:appauth:0.11.1") + + // Async + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..ddf4528 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project-specific ProGuard rules here. +# Keep this file empty until release optimization rules are needed. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cd16cb1 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt b/android/app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt new file mode 100644 index 0000000..47024ac --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt @@ -0,0 +1,7 @@ +package com.scarriffle.calendarr + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class CalendarrApplication : Application() diff --git a/android/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/android/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt new file mode 100644 index 0000000..54ee865 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -0,0 +1,26 @@ +package com.scarriffle.calendarr + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.scarriffle.calendarr.ui.CalendarrRoot +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Covers the window from the first frame (incl. warm-start), then hands + // off to the in-app branded splash which stays until data is loaded. + installSplashScreen() + // Draw edge-to-edge so system-bar insets reach every window, including + // ModalBottomSheets — otherwise navigationBarsPadding() inside the sheets + // resolves to 0 and the bottom menu rows hide behind the nav bar. + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + CalendarrRoot() + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt new file mode 100644 index 0000000..32b1e01 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -0,0 +1,731 @@ +package com.scarriffle.calendarr.data + +import com.scarriffle.calendarr.data.remote.ApiException +import com.scarriffle.calendarr.data.remote.OidcException +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException +import com.scarriffle.calendarr.data.remote.UnauthorizedException +import com.scarriffle.calendarr.data.remote.ensureSuccess +import com.scarriffle.calendarr.data.remote.errorDetail +import com.scarriffle.calendarr.data.remote.jsonBody +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalendarShareEntry +import com.scarriffle.calendarr.domain.model.DirectoryUser +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.domain.model.GroupMember +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.domain.model.OidcProvider +import com.scarriffle.calendarr.domain.model.UserProfile +import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.util.Dates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import retrofit2.HttpException +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +data class LoginResult(val token: String, val username: String, val isAdmin: Boolean) + +data class TotpSetup(val secret: String, val qrUrl: String) + +/** A single calendar's sync failure, surfaced alongside a (still-successful) events fetch. + * [calendarId] is null for account-wide failures (no single calendar is at fault). */ +data class SyncError(val source: String, val name: String, val calendarId: Int?, val message: String) + +/** Result of [CalendarRepository.fetchEvents]: the merged events plus any per-calendar sync failures. */ +data class EventsResult(val events: List, val errors: List) + +/** + * Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi], + * converts HTTP failures into [ApiException]s carrying the server's `detail` + * message, and parses the mixed-type event payloads with org.json. + */ +@Singleton +class CalendarRepository @Inject constructor( + private val apiProvider: ApiProvider, + private val credentialStore: CredentialStore, + private val settingsStore: SettingsStore, +) { + private val api get() = apiProvider.api() + + /** Current logged-in user id (0 if unknown) — for creator/owner comparisons. */ + val currentUserId: Int get() = credentialStore.userId + + private suspend fun guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) { + try { + block() + } catch (e: HttpException) { + if (e.code() == 401) throw UnauthorizedException() + throw ApiException(errorDetail(e.response()?.errorBody(), e.code())) + } + } + + // ---- Auth ---- + + suspend fun setupRequired(baseUrl: String): Boolean = withContext(Dispatchers.IO) { + val resp = apiProvider.apiFor(baseUrl).setupRequired() + if (!resp.isSuccessful) return@withContext false + val raw = resp.body()?.string() ?: return@withContext false + runCatching { JSONObject(raw).optBoolean("required", false) }.getOrDefault(false) + } + + suspend fun login( + baseUrl: String, + username: String, + password: String, + totpCode: String?, + rememberMe: Boolean, + ): LoginResult = withContext(Dispatchers.IO) { + val body = jsonBody( + "username" to username, + "password" to password, + "remember_me" to rememberMe, + "totp_code" to totpCode, + ) + val resp = apiProvider.apiFor(baseUrl).login(body) + if (resp.code() == 401) { + val detail = runCatching { + JSONObject(resp.errorBody()?.string() ?: "").optString("detail") + }.getOrNull() + if (detail == "2fa_required") throw TwoFactorRequiredException() + throw UnauthorizedException(detail?.takeIf { it.isNotBlank() } ?: "Benutzername oder Passwort falsch") + } + if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code())) + + val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort")) + persistLogin(baseUrl, json, fallbackUsername = username) + } + + // ---- Single sign-on (OpenID Connect) ---- + + /** Providers configured on the server. Empty list when SSO is off. */ + suspend fun oidcProviders(baseUrl: String): List = withContext(Dispatchers.IO) { + val resp = apiProvider.apiFor(baseUrl).oidcProviders() + if (!resp.isSuccessful) return@withContext emptyList() + val raw = resp.body()?.string() ?: return@withContext emptyList() + runCatching { + val json = JSONObject(raw) + if (!json.optBoolean("enabled", false)) return@runCatching emptyList() + val arr = json.optJSONArray("providers") ?: return@runCatching emptyList() + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + // Without a mobile client id the app cannot start a flow. + val clientId = o.optString("mobile_client_id").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + // Without an issuer the flow can only fail later with a + // misleading "unreachable" — drop the provider here instead. + val issuer = o.optString("issuer").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + OidcProvider( + key = o.optString("key"), + name = o.optString("name").takeIf { it.isNotBlank() } ?: o.optString("key"), + issuer = issuer, + // The server decides the scopes (it knows whether the + // provider grants offline_access); the app never adds any. + scopes = o.optString("mobile_scopes").takeIf { it.isNotBlank() } + ?: o.optString("scopes").takeIf { it.isNotBlank() } + ?: "openid profile email", + mobileClientId = clientId, + ) + } + }.getOrDefault(emptyList()) + } + + /** + * Trade a verified provider ID token for a Calendarr token. + * + * The app is a public client: the request carries no secret, only the id + * token the provider issued for [clientId] plus the [nonce] AppAuth bound + * into the flow. + */ + suspend fun exchangeOidc( + baseUrl: String, + provider: String, + clientId: String, + idToken: String, + accessToken: String?, + nonce: String?, + ): LoginResult = withContext(Dispatchers.IO) { + val body = jsonBody( + "provider" to provider, + "client_id" to clientId, + "id_token" to idToken, + "access_token" to accessToken, + "nonce" to nonce, + ) + val resp = apiProvider.apiFor(baseUrl).oidcExchange(body) + if (!resp.isSuccessful) { + // The server answers with a stable slug in `detail`; map the ones a + // user can act on, and fall back to the raw detail otherwise. + val detail = runCatching { + JSONObject(resp.errorBody()?.string() ?: "").optString("detail") + }.getOrNull().orEmpty() + throw OidcException(detail.takeIf { it.isNotBlank() } ?: "oidc_failed") + } + val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort")) + persistLogin(baseUrl, json, fallbackUsername = null) + } + + /** Shared tail of [login] and [exchangeOidc]: read the user, persist, reset. */ + private fun persistLogin(baseUrl: String, json: JSONObject, fallbackUsername: String?): LoginResult { + val token = json.optString("access_token").takeIf { it.isNotBlank() } + ?: throw ApiException("Antwort konnte nicht verarbeitet werden") + val user = json.optJSONObject("user") + // A blank username would be persisted as a blank display name too, so + // refuse rather than store a nameless identity. + val uname = user?.optString("username")?.takeIf { it.isNotBlank() } + ?: fallbackUsername + ?: throw ApiException("Antwort konnte nicht verarbeitet werden") + val isAdmin = user?.optBoolean("is_admin", false) ?: false + val uid = user?.optInt("id", 0) ?: 0 + val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname + + credentialStore.serverUrl = ApiProvider.normalize(baseUrl) + credentialStore.saveLogin(token, uname, isAdmin, uid, displayName) + apiProvider.invalidate() + return LoginResult(token, uname, isAdmin) + } + + // ---- Settings / profile ---- + + suspend fun getSettings(): AppSettings = guarded { api.getSettings() } + + /** Push only the values whose sync flag is on (partial update; the server + * leaves unsynced columns untouched), plus the account-wide flag map. */ + suspend fun updateSettings(s: AppSettings, flags: Map) = guarded { + val body = mutableMapOf() + fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value } + addIf("default_view", s.defaultView) + addIf("week_start_day", s.weekStartDay) + addIf("dim_past_events", s.dimPastEvents) + addIf("hour_height", s.hourHeight) + addIf("default_event_duration_minutes", s.defaultEventDurationMinutes) + // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. + addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL) + addIf("primary_color", s.primaryColor) + addIf("accent_color", s.accentColor) + addIf("today_color", s.todayColor) + addIf("text_color", s.textColor) + addIf("bg_color", s.backgroundColor) + addIf("line_color", s.lineColor) + addIf("month_divider_color", s.monthDividerColor) + addIf("month_label_color", s.monthLabelColor) + addIf("cache_months", s.cacheMonths) + addIf("month_view_paged", s.monthViewPaged) + // The flag map is always account-wide; send it every push. + body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>) + api.updateSettings(jsonBody(body)).ensureSuccess() + } + + suspend fun getProfile(): UserProfile = guarded { api.getProfile() } + + suspend fun updateEmail(email: String) = guarded { + api.updateEmail(jsonBody("email" to email)).ensureSuccess() + } + + suspend fun changePassword(current: String, new: String) = guarded { + api.changePassword( + jsonBody("current_password" to current, "new_password" to new) + ).ensureSuccess() + } + + suspend fun setup2fa(): TotpSetup = guarded { + val resp = api.setup2fa() + resp.ensureSuccess() + val json = JSONObject(resp.body()?.string() ?: "{}") + TotpSetup(json.optString("secret"), json.optString("qr_url")) + } + + suspend fun enable2fa(code: String) = guarded { + api.enable2fa(jsonBody("code" to code)).ensureSuccess() + } + + suspend fun disable2fa(password: String) = guarded { + api.disable2fa(jsonBody("password" to password)).ensureSuccess() + } + + // ---- Accounts ---- + + suspend fun getCalDAVAccounts(): List = guarded { api.getCalDAVAccounts() } + + suspend fun addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) = + guarded { + api.addCalDAVAccount( + jsonBody( + "name" to name, "url" to url, "username" to username, + "password" to password, "color" to color, + ) + ) + } + + suspend fun deleteCalDAVAccount(id: Int) = guarded { api.deleteCalDAVAccount(id).ensureSuccess() } + + suspend fun getLocalCalendars(): List = guarded { api.getLocalCalendars() } + + suspend fun addLocalCalendar( + name: String, color: String, + isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null, + ) = guarded { + api.addLocalCalendar(jsonBody(buildMap { + put("name", name) + put("color", color) + if (isBirthday) put("is_birthday", true) + if (birthdayNotifyDaysBefore != null) put("birthday_notify_days_before", birthdayNotifyDaysBefore) + })) + } + + suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() } + + suspend fun getICalSubscriptions(): List = guarded { api.getICalSubscriptions() } + + suspend fun addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) = + guarded { + api.addICalSubscription( + jsonBody( + "name" to name, "url" to url, "color" to color, + "refresh_minutes" to refreshMinutes, + ) + ) + } + + suspend fun deleteICalSubscription(id: Int) = guarded { api.deleteICalSubscription(id).ensureSuccess() } + + suspend fun getGoogleAccounts(): List = guarded { api.getGoogleAccounts() } + + suspend fun deleteGoogleAccount(id: Int) = guarded { api.deleteGoogleAccount(id).ensureSuccess() } + + suspend fun getHomeAssistantAccounts(): List = + guarded { api.getHomeAssistantAccounts() } + + suspend fun addHomeAssistantAccount(name: String, url: String, token: String) = + guarded { + api.addHomeAssistantAccount( + jsonBody("name" to name, "url" to url, "token" to token, "auth_method" to "token") + ) + } + + suspend fun deleteHomeAssistantAccount(id: Int) = + guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() } + + /** Change a local calendar's colour. Uses the colour-only endpoint so share + * recipients can recolour their view (own per-user colour) without needing + * write access or being able to rename the calendar. */ + suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded { + api.setLocalCalendarColor(id, jsonBody("color" to color)).ensureSuccess() + } + + /** Change an iCal subscription's colour. */ + suspend fun updateICalColor(id: Int, color: String) = guarded { + api.updateICalSubscription(id, jsonBody("color" to color)).ensureSuccess() + } + + /** Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). */ + suspend fun setCalendarColor(source: String, calendarId: Int, color: String) = guarded { + val body = jsonBody("color" to color) + when (source) { + "caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess() + "google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess() + "homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess() + else -> Unit + } + } + + /** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */ + suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded { + val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden) + when (source) { + "caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess() + "google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess() + "homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess() + else -> Unit + } + } + + /** Toggle a calendar's server-side `reminders_enabled` flag (all sources). */ + suspend fun setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Boolean) = guarded { + val body = jsonBody("reminders_enabled" to enabled) + when (source) { + "caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess() + "local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess() + "ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess() + "google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess() + "homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess() + else -> Unit + } + } + + /** Resolve all calendars the user can create events in. */ + suspend fun getWritableCalendars(): List = withContext(Dispatchers.IO) { + val result = mutableListOf() + runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()) + // Exclude read-only shared calendars — offering them in the event + // editor only leads to a 403 on save. Own + read_write (incl. group) + // calendars stay. + .filter { it.owned || it.permission == "read_write" } + .forEach { cal -> + result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id) + } + runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList()) + .filter { it.enabled } + .forEach { acc -> + acc.calendars.orEmpty().filter { it.enabled }.forEach { cal -> + result += WritableCalendar( + "caldav-${cal.id}", "${acc.name} – ${cal.name}", + cal.color ?: acc.color, "caldav", cal.id, + ) + } + } + runCatching { api.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().filter { it.enabled }.forEach { cal -> + result += WritableCalendar( + "google-${cal.id}", "${acc.email} – ${cal.name}", + cal.color ?: "#4285f4", "google", cal.id, + ) + } + } + runCatching { api.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().filter { it.enabled }.forEach { cal -> + result += WritableCalendar( + "ha-${cal.id}", "${acc.name} – ${cal.name}", + cal.color ?: "#46bdc6", "homeassistant", cal.id, + ) + } + } + result + } + + // ---- Events ---- + + suspend fun fetchEvents(start: Instant, end: Instant): EventsResult = withContext(Dispatchers.IO) { + val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end)) + resp.ensureSuccess() + val raw = resp.body()?.string() ?: return@withContext EventsResult(emptyList(), emptyList()) + val root = JSONObject(raw) + val arr = root.optJSONArray("events") + val events = buildList { + if (arr != null) for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + CalEvent.fromJson(obj)?.let { add(it) } + } + } + val errArr = root.optJSONArray("errors") + val errors = buildList { + if (errArr != null) for (i in 0 until errArr.length()) { + val obj = errArr.optJSONObject(i) ?: continue + add(SyncError( + source = obj.optString("source"), + name = obj.optString("name"), + calendarId = if (obj.has("calendar_id")) obj.optInt("calendar_id") else null, + message = obj.optString("message"), + )) + } + } + EventsResult(events, errors) + } + + suspend fun createLocalEvent( + calendarId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, reminders: List? = null, + rrule: String? = null, birthYear: Int? = null, externalUid: String? = null, + ) = guarded { + api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid)) + .ensureSuccess() + } + + suspend fun updateLocalEvent( + uid: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, reminders: List? = null, + rrule: String? = null, birthYear: Int? = null, externalUid: String? = null, + ) = guarded { + api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid)) + .ensureSuccess() + } + + // ---- Sharing ---- + + suspend fun getUserDirectory(): List = guarded { + val resp = api.getUserDirectory() + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(DirectoryUser(o.optInt("id"), o.optString("display_name"))) + } + } + } + + suspend fun getShares(calendarId: Int): List = guarded { + val resp = api.getShares(calendarId) + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(CalendarShareEntry(o.optInt("user_id"), o.optString("display_name"), o.optString("permission"))) + } + } + } + + suspend fun addShare(calendarId: Int, userId: Int, permission: String) = guarded { + api.addShare(calendarId, jsonBody("user_id" to userId, "permission" to permission)).ensureSuccess() + } + + suspend fun removeShare(calendarId: Int, userId: Int) = guarded { + api.removeShare(calendarId, userId).ensureSuccess() + } + + // ---- iCal import/export ---- + + suspend fun importIcs(calendarId: Int, part: okhttp3.MultipartBody.Part): Triple> = guarded { + val resp = api.importCalendar(calendarId, part) + resp.ensureSuccess() + val o = JSONObject(resp.body()?.string() ?: "{}") + val errors = o.optJSONArray("errors") + val errList = buildList { if (errors != null) for (i in 0 until errors.length()) add(errors.optString(i)) } + Triple(o.optInt("imported"), o.optInt("skipped"), errList) + } + + /** Build the multipart body from raw bytes and import (server form field: "file"). */ + suspend fun importIcsFile(calendarId: Int, bytes: ByteArray, filename: String): Triple> { + val body = bytes.toRequestBody("text/calendar".toMediaTypeOrNull()) + val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body) + return importIcs(calendarId, part) + } + + suspend fun exportIcs(calendarId: Int): ByteArray = guarded { + val resp = api.exportCalendar(calendarId) + resp.ensureSuccess() + resp.body()?.bytes() ?: ByteArray(0) + } + + // ---- Groups ---- + + suspend fun getGroups(): List = guarded { + val resp = api.getGroups() + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(Group( + id = o.optInt("id"), + name = o.optString("name"), + icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null }, + role = o.optString("role", "member"), + memberCount = o.optInt("member_count", 0), + groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"), + groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null }, + members = emptyList(), + )) + } + } + } + + suspend fun getGroup(id: Int): Group = guarded { + val resp = api.getGroup(id) + resp.ensureSuccess() + val o = JSONObject(resp.body()?.string() ?: "{}") + val membersArr = o.optJSONArray("members") + val members = buildList { + if (membersArr != null) for (i in 0 until membersArr.length()) { + val m = membersArr.optJSONObject(i) ?: continue + add(GroupMember( + m.optInt("id"), m.optString("display_name"), m.optString("role", "member"), + if (m.isNull("color")) null else m.optString("color").ifBlank { null }, + )) + } + } + Group( + id = o.optInt("id"), + name = o.optString("name"), + icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null }, + role = "", + memberCount = members.size, + groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"), + groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null }, + members = members, + ) + } + + suspend fun createGroup(name: String, memberIds: List, icon: String?) = guarded { + val arr = org.json.JSONArray() + memberIds.forEach { arr.put(it) } + api.createGroup(jsonBody("name" to name, "member_ids" to arr, "icon" to icon)).ensureSuccess() + } + + suspend fun updateGroup(id: Int, name: String?, icon: String?) = guarded { + api.updateGroup(id, jsonBody("name" to name, "icon" to icon)).ensureSuccess() + } + + suspend fun addGroupMember(groupId: Int, userId: Int) = guarded { + api.addGroupMember(groupId, jsonBody("user_id" to userId)).ensureSuccess() + } + + suspend fun removeGroupMember(groupId: Int, userId: Int) = guarded { + api.removeGroupMember(groupId, userId).ensureSuccess() + } + + suspend fun setGroupMemberColor(groupId: Int, userId: Int, color: String) = guarded { + api.setGroupMemberColor(groupId, userId, jsonBody("color" to color)).ensureSuccess() + } + + suspend fun deleteGroup(id: Int) = guarded { api.deleteGroup(id).ensureSuccess() } + + // ---- Profile & targeted settings ---- + + suspend fun updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Boolean? = null): String? = guarded { + val resp = api.updateProfile(jsonBody( + "display_name" to displayName, + "username" to username, + "email" to email, + "directory_hidden" to directoryHidden, + )) + resp.ensureSuccess() + runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull() + } + + suspend fun updatePrivateVisibility(value: String) = guarded { + api.updateSettings(jsonBody("private_event_visibility" to value)).ensureSuccess() + } + + suspend fun updateGroupVisibleCalendar(calendarId: Int?) = guarded { + // Send explicit JSON null to clear (jsonBody drops Kotlin nulls). + api.updateSettings(jsonBody("group_visible_calendar_id" to (calendarId ?: org.json.JSONObject.NULL))) + .ensureSuccess() + } + + suspend fun fetchGroupCombined(groupId: Int, start: Instant, end: Instant): List = withContext(Dispatchers.IO) { + val resp = api.fetchGroupCombined(groupId, Dates.isoUtc(start), Dates.isoUtc(end)) + resp.ensureSuccess() + val root = JSONObject(resp.body()?.string() ?: "{}") + val arr = root.optJSONArray("events") ?: return@withContext emptyList() + buildList { + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + CalEvent.fromJson(obj)?.let { add(it) } + } + } + } + + suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() } + + suspend fun createCalDAVEvent( + calendarId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = guarded { + api.createCalDAVEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color)) + .ensureSuccess() + } + + suspend fun updateCalDAVEvent( + uid: String, url: String, calendarId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = guarded { + api.updateCalDAVEvent( + uid, url, calendarId, + eventBody(null, title, start, end, isAllDay, location, description, color), + ).ensureSuccess() + } + + suspend fun deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) = + guarded { api.deleteCalDAVEvent(uid, url, calendarId).ensureSuccess() } + + suspend fun createGoogleEvent( + calendarDbId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.createGoogleEvent(simpleEventBody("calendar_db_id", calendarDbId, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun updateGoogleEvent( + gcalDbId: Int, eventId: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.updateGoogleEvent(gcalDbId, eventId, simpleEventBody(null, null, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun deleteGoogleEvent(gcalDbId: Int, eventId: String) = + guarded { api.deleteGoogleEvent(gcalDbId, eventId).ensureSuccess() } + + suspend fun createHAEvent( + calendarId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.createHAEvent(simpleEventBody("calendar_id", calendarId, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun updateHAEvent( + calendarId: Int, uid: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.updateHAEvent(calendarId, uid, simpleEventBody(null, null, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun deleteHAEvent(calendarId: Int, uid: String) = + guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() } + + /** Body for Google/HA events (no per-event colour). */ + private fun simpleEventBody( + calKey: String?, calId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = jsonBody( + buildMap { + if (calKey != null && calId != null) put(calKey, calId) + put("title", title) + put("start", Dates.format(start, isAllDay)) + put("end", Dates.format(end, isAllDay)) + put("allDay", isAllDay) + put("location", location) + put("description", description) + } + ) + + private fun eventBody( + calendarId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, reminders: List? = null, + rrule: String? = null, birthYear: Int? = null, externalUid: String? = null, + ) = jsonBody( + buildMap { + calendarId?.let { put("calendar_id", it) } + put("title", title) + put("start", Dates.format(start, isAllDay)) + put("end", Dates.format(end, isAllDay)) + put("allDay", isAllDay) + put("location", location) + put("description", description) + if (!color.isNullOrBlank()) put("color", color) + put("private", isPrivate) + if (reminders != null) put("reminders", org.json.JSONArray(reminders)) + if (!rrule.isNullOrBlank()) put("rrule", rrule) + if (birthYear != null) put("birth_year", birthYear) + if (!externalUid.isNullOrBlank()) put("external_uid", externalUid) + } + ) + + // ---- Birthdays (Contacts import) ---- + + suspend fun getBirthdayEntries(calendarId: Int): List = + 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() + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/ContactsReader.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/ContactsReader.kt new file mode 100644 index 0000000..630fe49 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/ContactsReader.kt @@ -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 { + val out = mutableListOf() + 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? = 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 + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt new file mode 100644 index 0000000..3f502a2 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt @@ -0,0 +1,128 @@ +package com.scarriffle.calendarr.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Securely stores the server URL and auth token in EncryptedSharedPreferences + * (Android's equivalent of the iOS Keychain). The iOS app keeps these in + * UserDefaults; on Android we encrypt them at rest. + */ +@Singleton +class CredentialStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs: SharedPreferences = run { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "calendarr_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + var serverUrl: String? + get() = prefs.getString(KEY_SERVER_URL, null) + set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply() + + var token: String? + get() = prefs.getString(KEY_TOKEN, null) + set(value) = prefs.edit().putString(KEY_TOKEN, value).apply() + + var username: String? + get() = prefs.getString(KEY_USERNAME, null) + set(value) = prefs.edit().putString(KEY_USERNAME, value).apply() + + var isAdmin: Boolean + get() = prefs.getBoolean(KEY_IS_ADMIN, false) + set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply() + + var userId: Int + get() = prefs.getInt(KEY_USER_ID, 0) + set(value) = prefs.edit().putInt(KEY_USER_ID, value).apply() + + var displayName: String? + get() = prefs.getString(KEY_DISPLAY_NAME, null) + set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply() + + /** + * Serialised AppAuth [net.openid.appauth.AuthState] for the SSO session — + * holds the provider's refresh token, so it must stay in the encrypted + * store and never in plain preferences. + */ + var oidcState: String? + get() = prefs.getString(KEY_OIDC_STATE, null) + set(value) = prefs.edit().putString(KEY_OIDC_STATE, value).apply() + + /** Provider key and client id the current SSO session was obtained with. */ + var oidcProvider: String? + get() = prefs.getString(KEY_OIDC_PROVIDER, null) + set(value) = prefs.edit().putString(KEY_OIDC_PROVIDER, value).apply() + + var oidcClientId: String? + get() = prefs.getString(KEY_OIDC_CLIENT_ID, null) + set(value) = prefs.edit().putString(KEY_OIDC_CLIENT_ID, value).apply() + + /** True once a server URL has been entered (setup step complete). */ + val isConfigured: Boolean get() = !serverUrl.isNullOrBlank() + + /** True once we hold an auth token (logged in). */ + val isLoggedIn: Boolean get() = !token.isNullOrBlank() + + fun saveLogin(token: String, username: String, isAdmin: Boolean, userId: Int = 0, displayName: String? = null) { + prefs.edit() + .putString(KEY_TOKEN, token) + .putString(KEY_USERNAME, username) + .putBoolean(KEY_IS_ADMIN, isAdmin) + .putInt(KEY_USER_ID, userId) + .putString(KEY_DISPLAY_NAME, displayName ?: username) + .apply() + } + + /** Persist the SSO session alongside the Calendarr token. */ + fun saveOidcSession(state: String, provider: String, clientId: String) { + prefs.edit() + .putString(KEY_OIDC_STATE, state) + .putString(KEY_OIDC_PROVIDER, provider) + .putString(KEY_OIDC_CLIENT_ID, clientId) + .apply() + } + + /** Clear the token (logout) but keep the server URL. */ + fun clearToken() { + prefs.edit() + .remove(KEY_TOKEN) + .remove(KEY_USERNAME) + .remove(KEY_IS_ADMIN) + .remove(KEY_OIDC_STATE) + .remove(KEY_OIDC_PROVIDER) + .remove(KEY_OIDC_CLIENT_ID) + .apply() + } + + /** Full reset, including the server URL ("switch server"). */ + fun clearAll() { + prefs.edit().clear().apply() + } + + private companion object { + const val KEY_SERVER_URL = "server_url" + const val KEY_TOKEN = "auth_token" + const val KEY_USERNAME = "username" + const val KEY_IS_ADMIN = "is_admin" + const val KEY_USER_ID = "user_id" + const val KEY_DISPLAY_NAME = "display_name" + const val KEY_OIDC_STATE = "oidc_state" + const val KEY_OIDC_PROVIDER = "oidc_provider" + const val KEY_OIDC_CLIENT_ID = "oidc_client_id" + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt new file mode 100644 index 0000000..a00343d --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt @@ -0,0 +1,160 @@ +package com.scarriffle.calendarr.data + +import android.content.Context +import android.content.Intent +import com.scarriffle.calendarr.domain.model.OidcProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** The app is registered as a *public* client — no secret is ever sent. */ +private const val REDIRECT_URI = "com.scarriffle.calendarr:/oauth2redirect" + +/** Outcome of a silent renewal against the identity provider. */ +data class RenewedTokens( + val idToken: String, + val accessToken: String?, + val nonce: String?, + val serialisedState: String, +) + +/** + * Drives the OpenID Connect Authorization Code Flow with PKCE against the + * identity provider, using AppAuth and a Chrome Custom Tab. + * + * Deliberately never uses a WebView: a custom tab keeps the credentials in the + * browser's own security context and lets the provider's session cookies work. + */ +@Singleton +class OidcAuthenticator @Inject constructor( + @ApplicationContext private val context: Context, +) { + + @Volatile + private var service: AuthorizationService? = null + + private fun service(): AuthorizationService = + service ?: synchronized(this) { + service ?: AuthorizationService(context).also { service = it } + } + + /** Fetch the provider's OpenID configuration from its issuer URL. */ + suspend fun discover(provider: OidcProvider): AuthorizationServiceConfiguration = + suspendCancellableCoroutine { cont -> + AuthorizationServiceConfiguration.fetchFromIssuer( + android.net.Uri.parse(provider.issuer) + ) { config, ex -> + when { + config != null -> cont.resume(config) + else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR) + } + } + } + + /** + * Build the authorization request. AppAuth generates the PKCE verifier and + * the nonce itself; both are carried on the returned request, and the nonce + * has to travel to our server later so it can verify the ID token. + */ + fun buildRequest( + config: AuthorizationServiceConfiguration, + provider: OidcProvider, + ): AuthorizationRequest { + // Exactly what the server asked for — including offline_access when it + // configured that. A scope the provider does not know would make it + // reject the whole authorization request. + val scopes = buildSet { + addAll(provider.scopes.split(" ").filter { it.isNotBlank() }) + add("openid") + } + return AuthorizationRequest.Builder( + config, + requireNotNull(provider.mobileClientId) { "provider has no mobile client id" }, + ResponseTypeValues.CODE, + android.net.Uri.parse(REDIRECT_URI), + ) + .setScopes(scopes) + .build() + } + + /** Intent that opens the provider's login page in a Custom Tab. */ + fun authorizationIntent(request: AuthorizationRequest): Intent = + service().getAuthorizationRequestIntent(request) + + /** + * Complete the code exchange with the provider. + * + * PKCE means no client secret is involved: the verifier AppAuth kept from + * [buildRequest] is what proves this is the same app that started the flow. + */ + suspend fun exchangeCode(data: Intent): Pair { + val response = AuthorizationResponse.fromIntent(data) + val error = AuthorizationException.fromIntent(data) + if (response == null) throw error ?: AuthorizationException.GeneralErrors.NETWORK_ERROR + + val tokens: TokenResponse = suspendCancellableCoroutine { cont -> + service().performTokenRequest(response.createTokenExchangeRequest()) { tokens, ex -> + when { + tokens != null -> cont.resume(tokens) + else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR) + } + } + } + return response to tokens + } + + /** + * Ask the provider for a fresh ID token using a stored [AuthState]. + * + * Returns null when there is no session to renew. The refreshed state is + * handed back too, because the provider may have rotated the refresh token. + */ + suspend fun renewIdToken(serialisedState: String): RenewedTokens? { + val state = runCatching { AuthState.jsonDeserialize(serialisedState) }.getOrNull() + ?: return null + + val tokens: Pair = suspendCancellableCoroutine { cont -> + state.performActionWithFreshTokens(service()) { accessToken, idToken, ex -> + if (ex != null) { + cont.resumeWithException(ex) + } else { + cont.resume(idToken to accessToken) + } + } + } + + val idToken = tokens.first ?: return null + return RenewedTokens( + idToken = idToken, + accessToken = tokens.second, + // The original request rides along inside the state, so the nonce + // survives. A refreshed token often drops the claim, which the + // server tolerates — but when it keeps it, it has to match. + nonce = state.lastAuthorizationResponse?.request?.nonce, + serialisedState = state.jsonSerializeString(), + ) + } + + /** + * Serialised AuthState (including the refresh token) for secure storage. + * + * Both arguments are non-null on purpose: `AuthState.update` asserts that + * exactly one of (response, exception) is set and throws otherwise. + */ + fun stateFor(response: AuthorizationResponse, tokens: TokenResponse): String = + AuthState().apply { + update(response, null) + update(tokens, null) + }.jsonSerializeString() +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt new file mode 100644 index 0000000..5ddd6ba --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -0,0 +1,243 @@ +package com.scarriffle.calendarr.data + +import android.content.Context +import android.content.SharedPreferences +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Local persistence for cached appearance settings and the per-device + * calendar visibility sets (hidden / banished). Mirrors the UserDefaults + * usage in the iOS `CalendarStore`. + */ +@Singleton +class SettingsStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences("calendarr_prefs", Context.MODE_PRIVATE) + + // --- Appearance settings cache (mirror of the server) --- + + fun loadSettings(): AppSettings = AppSettings( + defaultView = prefs.getString(K_DEFAULT_VIEW, null) ?: "month", + weekStartDay = prefs.getString(K_WEEK_START, null) ?: "monday", + primaryColor = prefs.getString(K_PRIMARY, null) ?: "#4285f4", + accentColor = prefs.getString(K_ACCENT, null) ?: "#ea4335", + todayColor = prefs.getString(K_TODAY, null) ?: "#4285f4", + dimPastEvents = prefs.getBoolean(K_DIM_PAST, false), + textContrast = prefs.getInt(K_TEXT_CONTRAST, 3), + lineContrast = prefs.getInt(K_LINE_CONTRAST, 3), + hourHeight = prefs.getInt(K_HOUR_HEIGHT, 60), + language = prefs.getString(K_LANGUAGE, null) ?: "de", + monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", + monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", + textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF", + backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000", + lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52", + defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 }, + defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60), + cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3), + monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false), + ) + + fun saveSettings(s: AppSettings) { + prefs.edit() + .putString(K_DEFAULT_VIEW, s.defaultView) + .putString(K_WEEK_START, s.weekStartDay) + .putString(K_PRIMARY, s.primaryColor) + .putString(K_ACCENT, s.accentColor) + .putString(K_TODAY, s.todayColor) + .putBoolean(K_DIM_PAST, s.dimPastEvents) + .putInt(K_TEXT_CONTRAST, s.textContrast) + .putInt(K_LINE_CONTRAST, s.lineContrast) + .putInt(K_HOUR_HEIGHT, s.hourHeight) + .putString(K_LANGUAGE, s.language) + .putString(K_DIVIDER, s.monthDividerColor) + .putString(K_LABEL, s.monthLabelColor) + .putString(K_TEXT_COLOR, s.textColor) + .putString(K_BG_COLOR, s.backgroundColor) + .putString(K_LINE_COLOR, s.lineColor) + .putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1) + .putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes) + .putInt(K_CACHE_MONTHS, s.cacheMonths) + .putBoolean(K_MONTH_PAGED, s.monthViewPaged) + .apply() + } + + // --- Per-setting cross-device sync flags (account-wide; server is authority) --- + + /** Keys this Android client can sync. Excludes language (device "system" has + * no server value) and text/line contrast (iOS-style opacity, device-local). */ + val syncableKeys: List = 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 = syncableKeys.associateWith { key -> + key != "cache_months" && key != "month_view_paged" + } + + fun loadSyncFlags(): Map { + 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) { + prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply() + } + + fun storeServerFlags(server: Map?) { + if (server == null) return + val f = loadSyncFlags().toMutableMap() + for (key in syncableKeys) server[key]?.let { f[key] = it } + writeSyncFlags(f) + } + + fun setSyncFlag(key: String, on: Boolean) { + val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f) + } + + fun setAllSyncFlags(on: Boolean) { + val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f) + } + + /** Merge a server snapshot with local values honouring the (refreshed) flags: + * synced keys take the server value, others keep the local value. Persists + * the result and returns the effective settings. */ + fun applyServerPull(server: AppSettings): AppSettings { + storeServerFlags(server.syncFlags) + val flags = loadSyncFlags() + val local = loadSettings() + fun on(key: String) = flags[key] == true + val eff = local.copy( + defaultView = if (on("default_view")) server.defaultView else local.defaultView, + weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay, + dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents, + hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight, + defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes, + defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes, + primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor, + accentColor = if (on("accent_color")) server.accentColor else local.accentColor, + todayColor = if (on("today_color")) server.todayColor else local.todayColor, + textColor = if (on("text_color")) server.textColor else local.textColor, + backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor, + lineColor = if (on("line_color")) server.lineColor else local.lineColor, + monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor, + monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor, + cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths, + monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged, + // Device-local (never synced): language + contrast levels. + language = local.language, + textContrast = local.textContrast, + lineContrast = local.lineContrast, + ) + saveSettings(eff) + return eff + } + + /** Device-local cache range in months around today (default 3). */ + var cacheMonths: Int + get() = prefs.getInt(K_CACHE_MONTHS, 3) + set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply() + + /** Device-local: month view as horizontal paged (swipe) instead of the + * continuous vertical scroll feed (default false = scroll). */ + var monthViewPaged: Boolean + get() = prefs.getBoolean(K_MONTH_PAGED, false) + set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply() + + /** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */ + var hideMenuButton: Boolean + get() = prefs.getBoolean(K_HIDE_MENU, false) + set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply() + + /** Whether this device mirrors its Contacts birthdays into the birthday calendar. */ + var birthdaysSyncEnabled: Boolean + get() = prefs.getBoolean(K_BDAY_ENABLED, false) + set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply() + + /** Stable per-install id used to scope this device's contact-birthday rows. */ + val birthdaysDeviceId: String + get() { + prefs.getString(K_BDAY_DEVICE, null)?.let { return it } + val id = java.util.UUID.randomUUID().toString() + prefs.edit().putString(K_BDAY_DEVICE, id).apply() + return id + } + + // --- Hidden calendars ("source:id") --- + + var hiddenCalendarKeys: Set + get() = prefs.getStringSet(K_HIDDEN, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_HIDDEN, value).apply() + + // --- Banished calendars ("source:id") --- + + var banishedCalendarKeys: Set + get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply() + + // --- Reminder-disabled calendars ("source:id") --- + // Mirrors the server's per-calendar `reminders_enabled` flag so the + // notification scheduler can skip muted calendars without deleting any + // event reminders. + + var reminderDisabledCalendarKeys: Set + get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply() + + // --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) --- + + var calendarOrder: List + get() { + val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList() + return runCatching { + val arr = org.json.JSONArray(raw) + (0 until arr.length()).map { arr.getString(it) } + }.getOrDefault(emptyList()) + } + set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply() + + private companion object { + const val K_DEFAULT_VIEW = "default_view" + const val K_WEEK_START = "week_start_day" + const val K_PRIMARY = "primary_color" + const val K_ACCENT = "accent_color" + const val K_TODAY = "today_color" + const val K_DIM_PAST = "dim_past_events" + const val K_TEXT_CONTRAST = "text_contrast" + const val K_LINE_CONTRAST = "line_contrast" + const val K_HOUR_HEIGHT = "hour_height" + const val K_LANGUAGE = "language" + const val K_DIVIDER = "month_divider_color" + const val K_LABEL = "month_label_color" + const val K_TEXT_COLOR = "text_color" + const val K_BG_COLOR = "bg_color" + const val K_LINE_COLOR = "line_color" + const val K_SYNC_FLAGS = "sync_flags" + const val K_DEFAULT_REMINDER = "default_reminder_minutes" + const val K_DEFAULT_DURATION = "default_event_duration_minutes" + const val K_CACHE_MONTHS = "cache_months" + const val K_MONTH_PAGED = "month_view_paged" + const val K_HIDE_MENU = "hide_menu_button" + const val K_BDAY_ENABLED = "birthdays_sync_enabled" + const val K_BDAY_DEVICE = "birthdays_device_id" + const val K_HIDDEN = "hidden_calendar_keys" + const val K_BANISHED = "banished_calendar_keys" + const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys" + const val K_CAL_ORDER = "calendar_order" + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt new file mode 100644 index 0000000..3db4719 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt @@ -0,0 +1,42 @@ +package com.scarriffle.calendarr.data.remote + +import okhttp3.ResponseBody +import org.json.JSONObject +import retrofit2.Response + +/** Generic server error carrying the `detail` message when available. */ +open class ApiException(message: String) : Exception(message) + +/** Credentials rejected (HTTP 401, not a 2FA prompt). Carries the server detail. */ +class UnauthorizedException(message: String = "Benutzername oder Passwort falsch") : ApiException(message) + +/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */ +class TwoFactorRequiredException : ApiException("2FA-Code erforderlich") + +/** Extract the `detail` field from an error response body, with a fallback. */ +fun errorDetail(errorBody: ResponseBody?, status: Int): String { + val raw = runCatching { errorBody?.string() }.getOrNull() + if (!raw.isNullOrBlank()) { + runCatching { + val detail = JSONObject(raw).optString("detail") + if (detail.isNotBlank()) return detail + } + } + return "Fehler $status" +} + +/** Throw an [ApiException] if the response is unsuccessful. */ +fun Response<*>.ensureSuccess() { + if (!isSuccessful) { + if (code() == 401) throw UnauthorizedException() + throw ApiException(errorDetail(errorBody(), code())) + } +} + + +/** + * An SSO exchange the server refused. [slug] is the stable machine-readable + * reason (e.g. `oidc_account_not_linked`), which the UI maps to a translated + * message; unknown slugs fall back to a generic one. + */ +class OidcException(val slug: String) : Exception(slug) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt new file mode 100644 index 0000000..f7c7979 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt @@ -0,0 +1,71 @@ +package com.scarriffle.calendarr.data.remote + +import com.scarriffle.calendarr.data.CredentialStore +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Builds [CalendarrApi] instances. The base URL is dynamic (the user's server), + * so the cached instance is rebuilt whenever the stored server URL changes. + */ +@Singleton +class ApiProvider @Inject constructor( + private val credentialStore: CredentialStore, + private val moshi: Moshi, + private val authInterceptor: AuthInterceptor, +) { + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + } + + @Volatile private var cached: Pair? = null + + /** API bound to the currently stored server URL. Requires a configured server. */ + fun api(): CalendarrApi { + val base = normalize(credentialStore.serverUrl ?: error("No server URL configured")) + cached?.let { (url, api) -> if (url == base) return api } + val api = build(base) + cached = base to api + return api + } + + /** API for an explicit base URL (used during server setup / login). */ + fun apiFor(baseUrl: String): CalendarrApi = build(normalize(baseUrl)) + + /** Force the cached instance to be rebuilt (e.g. after switching servers). */ + fun invalidate() { cached = null } + + private fun build(baseUrl: String): CalendarrApi = + Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(CalendarrApi::class.java) + + companion object { + /** Ensure https:// prefix, strip trailing slashes, then add exactly one. */ + fun normalize(raw: String): String { + var url = raw.trim() + if (url.isEmpty()) return url + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://$url" + } + url = url.trimEnd('/') + return "$url/" + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..ab34832 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt @@ -0,0 +1,27 @@ +package com.scarriffle.calendarr.data.remote + +import com.scarriffle.calendarr.data.CredentialStore +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +/** + * Attaches the current bearer token (read dynamically from the + * [CredentialStore]) to every request. Requests made before login simply + * carry no Authorization header. + */ +class AuthInterceptor @Inject constructor( + private val credentialStore: CredentialStore, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = credentialStore.token + val request = if (!token.isNullOrBlank()) { + chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + return chain.proceed(request) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt new file mode 100644 index 0000000..3b4d75d --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -0,0 +1,289 @@ +package com.scarriffle.calendarr.data.remote + +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.domain.model.UserProfile +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit definition of the Calendarr server HTTP API. + * + * Endpoints whose responses contain mixed-type fields (event `id` / + * `calendar_id` arrive as either String or Int) return a raw [ResponseBody] + * and are parsed manually with org.json, mirroring the iOS client. + */ +interface CalendarrApi { + + // ---- Auth ---- + + @GET("api/auth/setup-required") + suspend fun setupRequired(): Response + + /** Configured OIDC providers. Public — used before login. */ + @GET("api/auth/oidc/providers") + suspend fun oidcProviders(): Response + + /** Trade a provider ID token for a Calendarr token. Same response shape as + * [login]. Public client: never send a client secret. */ + @POST("api/auth/oidc/exchange") + suspend fun oidcExchange(@Body body: RequestBody): Response + + @POST("api/auth/login") + suspend fun login(@Body body: RequestBody): Response + + @GET("api/auth/me") + suspend fun getProfile(): UserProfile + + // ---- Settings ---- + + @GET("api/settings/") + suspend fun getSettings(): AppSettings + + @PUT("api/settings/") + suspend fun updateSettings(@Body body: RequestBody): Response + + @PUT("api/profile/") + suspend fun updateProfile(@Body body: RequestBody): Response + + // ---- Sharing ---- + + @GET("api/users/directory") + suspend fun getUserDirectory(): Response + + @GET("api/local/calendars/{id}/shares") + suspend fun getShares(@Path("id") id: Int): Response + + @POST("api/local/calendars/{id}/shares") + suspend fun addShare(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/local/calendars/{id}/shares/{userId}") + suspend fun removeShare(@Path("id") id: Int, @Path("userId") userId: Int): Response + + // ---- iCal import/export ---- + + @Multipart + @POST("api/local/calendars/{id}/import") + suspend fun importCalendar(@Path("id") id: Int, @Part part: MultipartBody.Part): Response + + @GET("api/local/calendars/{id}/export") + suspend fun exportCalendar(@Path("id") id: Int): Response + + // ---- Groups ---- + + @GET("api/groups/") + suspend fun getGroups(): Response + + @GET("api/groups/{id}") + suspend fun getGroup(@Path("id") id: Int): Response + + @POST("api/groups/") + suspend fun createGroup(@Body body: RequestBody): Response + + @PUT("api/groups/{id}") + suspend fun updateGroup(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/groups/{id}") + suspend fun deleteGroup(@Path("id") id: Int): Response + + @POST("api/groups/{id}/members") + suspend fun addGroupMember(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/groups/{id}/members/{userId}") + suspend fun removeGroupMember(@Path("id") id: Int, @Path("userId") userId: Int): Response + + @PUT("api/groups/{id}/members/{userId}/color") + suspend fun setGroupMemberColor(@Path("id") id: Int, @Path("userId") userId: Int, @Body body: RequestBody): Response + + @GET("api/groups/{id}/combined") + suspend fun fetchGroupCombined( + @Path("id") id: Int, + @Query("start") start: String, + @Query("end") end: String, + ): Response + + // ---- Profile ---- + + @PATCH("api/profile/") + suspend fun updateEmail(@Body body: RequestBody): Response + + @POST("api/profile/password") + suspend fun changePassword(@Body body: RequestBody): Response + + @POST("api/profile/2fa/setup") + suspend fun setup2fa(): Response + + @POST("api/profile/2fa/enable") + suspend fun enable2fa(@Body body: RequestBody): Response + + @POST("api/profile/2fa/disable") + suspend fun disable2fa(@Body body: RequestBody): Response + + // ---- CalDAV ---- + + @GET("api/caldav/accounts") + suspend fun getCalDAVAccounts(): List + + @POST("api/caldav/accounts") + suspend fun addCalDAVAccount(@Body body: RequestBody): CalDAVAccount + + @DELETE("api/caldav/accounts/{id}") + suspend fun deleteCalDAVAccount(@Path("id") id: Int): Response + + @POST("api/caldav/accounts/{id}/sync") + suspend fun syncCalDAVAccount(@Path("id") id: Int): Response + + @PUT("api/caldav/calendars/{id}") + suspend fun updateCalDAVCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Local ---- + + @GET("api/local/calendars") + suspend fun getLocalCalendars(): List + + @POST("api/local/calendars") + suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar + + @PUT("api/local/calendars/{id}") + suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // Colour-only update that works for owners AND share recipients (recipients + // get their own per-user colour; owners change the calendar's colour). + @PUT("api/local/calendars/{id}/color") + suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/local/calendars/{id}") + suspend fun deleteLocalCalendar(@Path("id") id: Int): Response + + // ---- iCal subscriptions ---- + + @GET("api/ical/subscriptions") + suspend fun getICalSubscriptions(): List + + @POST("api/ical/subscriptions") + suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription + + @PUT("api/ical/subscriptions/{id}") + suspend fun updateICalSubscription(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/ical/subscriptions/{id}") + suspend fun deleteICalSubscription(@Path("id") id: Int): Response + + @POST("api/ical/subscriptions/{id}/refresh") + suspend fun refreshICalSubscription(@Path("id") id: Int): Response + + // ---- Google ---- + + @GET("api/google/accounts") + suspend fun getGoogleAccounts(): List + + @DELETE("api/google/accounts/{id}") + suspend fun deleteGoogleAccount(@Path("id") id: Int): Response + + @PUT("api/google/calendars/{id}") + suspend fun updateGoogleCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Home Assistant ---- + + @GET("api/homeassistant/accounts") + suspend fun getHomeAssistantAccounts(): List + + @POST("api/homeassistant/accounts") + suspend fun addHomeAssistantAccount(@Body body: RequestBody): HomeAssistantAccount + + @DELETE("api/homeassistant/accounts/{id}") + suspend fun deleteHomeAssistantAccount(@Path("id") id: Int): Response + + @PUT("api/homeassistant/calendars/{id}") + suspend fun updateHACalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Events ---- + + @GET("api/caldav/events") + suspend fun fetchEvents( + @Query("start") start: String, + @Query("end") end: String, + ): Response + + @POST("api/local/events") + suspend fun createLocalEvent(@Body body: RequestBody): Response + + @PUT("api/local/events/{uid}") + suspend fun updateLocalEvent(@Path("uid") uid: String, @Body body: RequestBody): Response + + @DELETE("api/local/events/{uid}") + suspend fun deleteLocalEvent(@Path("uid") uid: String): Response + + @GET("api/local/calendars/{id}/birthdays") + suspend fun getBirthdays(@Path("id") id: Int): List + + @POST("api/birthdays/sync-report") + suspend fun reportBirthdaySync(@Body body: RequestBody): Response + + @POST("api/caldav/events") + suspend fun createCalDAVEvent(@Body body: RequestBody): Response + + @PUT("api/caldav/events/{uid}") + suspend fun updateCalDAVEvent( + @Path("uid") uid: String, + @Query("event_url") eventUrl: String, + @Query("calendar_id") calendarId: Int?, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/caldav/events/{uid}", hasBody = false) + suspend fun deleteCalDAVEvent( + @Path("uid") uid: String, + @Query("event_url") eventUrl: String, + @Query("calendar_id") calendarId: Int?, + ): Response + + @POST("api/google/events") + suspend fun createGoogleEvent(@Body body: RequestBody): Response + + @PUT("api/google/events/{gcalDbId}/{eventId}") + suspend fun updateGoogleEvent( + @Path("gcalDbId") gcalDbId: Int, + @Path("eventId") eventId: String, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/google/events/{gcalDbId}/{eventId}", hasBody = false) + suspend fun deleteGoogleEvent( + @Path("gcalDbId") gcalDbId: Int, + @Path("eventId") eventId: String, + ): Response + + @POST("api/homeassistant/events") + suspend fun createHAEvent(@Body body: RequestBody): Response + + @PUT("api/homeassistant/events/{calendarId}/{uid}") + suspend fun updateHAEvent( + @Path("calendarId") calendarId: Int, + @Path("uid") uid: String, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/homeassistant/events/{calendarId}/{uid}", hasBody = false) + suspend fun deleteHAEvent( + @Path("calendarId") calendarId: Int, + @Path("uid") uid: String, + ): Response +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt new file mode 100644 index 0000000..b6d08d5 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt @@ -0,0 +1,26 @@ +package com.scarriffle.calendarr.data.remote + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +private val JSON = "application/json; charset=utf-8".toMediaType() + +/** Build a JSON request body from key/value pairs, dropping null values. */ +fun jsonBody(vararg pairs: Pair): RequestBody { + val obj = JSONObject() + for ((k, v) in pairs) { + if (v != null) obj.put(k, v) + } + return obj.toString().toRequestBody(JSON) +} + +/** Build a JSON request body from a map, dropping null values. */ +fun jsonBody(map: Map): RequestBody { + val obj = JSONObject() + for ((k, v) in map) { + if (v != null) obj.put(k, v) + } + return obj.toString().toRequestBody(JSON) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt b/android/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt new file mode 100644 index 0000000..95ba7a7 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt @@ -0,0 +1,21 @@ +package com.scarriffle.calendarr.di + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideMoshi(): Moshi = + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt new file mode 100644 index 0000000..cd7895b --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -0,0 +1,123 @@ +package com.scarriffle.calendarr.domain.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class CalDAVAccount( + val id: Int, + val name: String = "", + val url: String = "", + val username: String = "", + val color: String = "#4285f4", + val enabled: Boolean = true, + val calendars: List? = null, +) + +@JsonClass(generateAdapter = false) +data class CalDAVCalendar( + val id: Int, + val name: String = "", + val color: String? = null, + val enabled: Boolean = true, + @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, +) + +@JsonClass(generateAdapter = false) +data class LocalCalendar( + val id: Int, + val name: String = "", + val color: String = "#34a853", + val enabled: Boolean = true, + val type: String = "local", + val owned: Boolean = true, + @Json(name = "shared_by") val sharedBy: String? = null, + val permission: String? = null, + val group: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, + @Json(name = "is_birthday") val isBirthday: Boolean = false, + @Json(name = "birthday_notify_days_before") val birthdayNotifyDaysBefore: Int? = null, +) + +@JsonClass(generateAdapter = false) +data class ICalSubscription( + val id: Int, + val name: String = "", + val url: String = "", + val color: String = "#46bdc6", + val enabled: Boolean = true, + @Json(name = "refresh_minutes") val refreshMinutes: Int = 60, + @Json(name = "last_fetched") val lastFetched: String? = null, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, +) + +@JsonClass(generateAdapter = false) +data class GoogleAccount( + val id: Int, + val email: String = "", + val calendars: List? = null, +) + +@JsonClass(generateAdapter = false) +data class GoogleCalendar( + val id: Int, + val name: String = "", + val color: String? = null, + val enabled: Boolean = true, + @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, +) + +@JsonClass(generateAdapter = false) +data class HomeAssistantAccount( + val id: Int, + val name: String = "", + val url: String = "", + @Json(name = "auth_method") val authMethod: String = "token", + val calendars: List? = null, +) + +@JsonClass(generateAdapter = false) +data class HACalendar( + val id: Int, + val name: String = "", + @Json(name = "entity_id") val entityId: String = "", + val color: String? = null, + val enabled: Boolean = true, + @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, +) + +@JsonClass(generateAdapter = false) +data class UserProfile( + val id: Int, + val username: String = "", + @Json(name = "display_name") val displayName: String? = null, + val email: String? = null, + @Json(name = "is_admin") val isAdmin: Boolean = false, + @Json(name = "has_avatar") val hasAvatar: Boolean = false, + @Json(name = "totp_enabled") val totpEnabled: Boolean = false, + @Json(name = "directory_hidden") val directoryHidden: Boolean = false, +) + +/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced + * rows carry an external_uid ("contact::"); 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). */ +data class WritableCalendar( + val id: String, + val name: String, + val color: String, + val source: String, + val numericId: Int, +) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt new file mode 100644 index 0000000..b286fbc --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -0,0 +1,44 @@ +package com.scarriffle.calendarr.domain.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * User appearance / behaviour settings, synced with the server. + * Mirrors iOS `AppSettings`. Missing keys fall back to the defaults below + * (the server only persists a subset). + */ +@JsonClass(generateAdapter = false) +data class AppSettings( + @Json(name = "default_view") val defaultView: String = "month", + @Json(name = "week_start_day") val weekStartDay: String = "monday", + @Json(name = "primary_color") val primaryColor: String = "#4285f4", + @Json(name = "accent_color") val accentColor: String = "#ea4335", + @Json(name = "today_color") val todayColor: String = "#4285f4", + @Json(name = "dim_past_events") val dimPastEvents: Boolean = false, + @Json(name = "text_contrast") val textContrast: Int = 3, + @Json(name = "line_contrast") val lineContrast: Int = 3, + @Json(name = "hour_height") val hourHeight: Int = 60, + @Json(name = "language") val language: String = "de", + @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", + @Json(name = "month_label_color") val monthLabelColor: String = "#7090c0", + // Text / background / line colours drive the Material theme (onBackground, + // background, outline) so the whole app follows them, matching web/iOS. + @Json(name = "text_color") val textColor: String = "#FFFFFF", + @Json(name = "bg_color") val backgroundColor: String = "#000000", + @Json(name = "line_color") val lineColor: String = "#3A3A52", + // How this user's private events appear to other group members: 'hidden' | 'busy'. + @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", + @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, + // Minutes-before-start applied to all events client-side; null = off. + @Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null, + // Duration (minutes) applied to a newly created event's end time. + @Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60, + // Preload range in months (device-local by default) and month-view paging. + @Json(name = "cache_months") val cacheMonths: Int = 3, + @Json(name = "month_view_paged") val monthViewPaged: Boolean = false, + // Account-wide per-setting sync flags, fully resolved by the server. + @Json(name = "sync_flags") val syncFlags: Map? = null, +) { + val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt new file mode 100644 index 0000000..65c3e61 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -0,0 +1,145 @@ +package com.scarriffle.calendarr.domain.model + +import com.scarriffle.calendarr.util.Dates +import org.json.JSONObject +import java.time.Instant + +/** + * A unified calendar event, blended from all server sources + * (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`. + */ +/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */ +data class EventPerson(val id: Int?, val displayName: String) + +data class CalEvent( + val id: String, + val url: String, + val title: String, + val startDate: Instant, + val endDate: Instant, + val isAllDay: Boolean, + val location: String, + val notes: String, + val color: String?, + val calendarId: String, + val calendarName: String, + val calendarColor: String, + val source: String, + val creator: EventPerson? = null, + val isPrivate: Boolean = false, + // Only set in the group combined view: + val owner: EventPerson? = null, + val isGroupEvent: Boolean = false, + val displayColor: String? = null, + // Server-decorated title for the group combined view (group icon / owner + // prefix); rendered in group mode while `title` stays raw for editing. + val displayTitle: String? = null, + // Reminder offsets in minutes-before-start (0 = at start). Local events only. + val reminders: List = emptyList(), + // True for events from a calendar shared with the user read-only. + val readOnly: Boolean = false, + // True for events from a birthday calendar — clients show a cake icon and + // the server bakes the age into `displayTitle`. + val isBirthday: Boolean = false, +) { + /** + * Title to render: the server-decorated one (birthday age, group prefix) + * wins over the raw title, which is kept for editing. + */ + val renderTitle: String + get() = displayTitle?.takeIf { it.isNotBlank() } ?: title + /** + * Group view supplies a server-resolved colour (display_color); otherwise + * per-event override colour, then the calendar's colour, then a stable + * per-calendar palette colour (so events never collapse to one default). + */ + val effectiveColor: String + get() = displayColor?.takeIf { it.isNotBlank() } + ?: color?.takeIf { it.isNotBlank() } + ?: calendarColor.takeIf { it.isNotBlank() } + ?: fallbackColorFor("$source:$calendarId") + + companion object { + private val FALLBACK_PALETTE = listOf( + "#34a853", "#4285f4", "#ea4335", "#fbbc05", + "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", + ) + + private fun fallbackColorFor(key: String): String { + val idx = (key.hashCode().and(Int.MAX_VALUE)) % FALLBACK_PALETTE.size + return FALLBACK_PALETTE[idx] + } + + /** + * Read a string field, treating JSON null correctly. Android's + * [JSONObject.optString] returns the literal string "null" for a JSON + * null value, which previously made every event blue (color "null" → + * unparseable → fallback) and showed "null" for empty location/notes. + */ + private fun JSONObject.strOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key, "").takeIf { it.isNotBlank() && it != "null" } + } + + /** Parse a {id, display_name} person object (creator/owner). */ + private fun personFrom(json: JSONObject?, key: String): EventPerson? { + val obj = json?.optJSONObject(key) ?: return null + val name = obj.strOrNull("display_name") ?: return null + val id = if (obj.isNull("id")) null else obj.opt("id")?.let { + when (it) { is Number -> it.toInt(); is String -> it.toIntOrNull(); else -> null } + } + return EventPerson(id, name) + } + + /** Parse one event object from the `/api/caldav/events` aggregate response. */ + fun fromJson(json: JSONObject): CalEvent? { + val title = json.strOrNull("title") ?: return null + val startStr = json.strOrNull("start") ?: return null + val endStr = json.strOrNull("end") ?: return null + + // id may be a String (local UUID) or an Int (CalDAV numeric) + val id: String = when (val raw = json.opt("id")) { + is String -> raw + is Number -> raw.toString() + else -> return null + } + + val isAllDay = json.optBoolean("allDay", false) + val start = Dates.parse(startStr, isAllDay) ?: return null + val end = Dates.parse(endStr, isAllDay) ?: return null + + // calendar_id arrives as raw numeric (caldav) or "-" string + val calendarId = when (val raw = json.opt("calendar_id")) { + null, JSONObject.NULL -> "" + else -> raw.toString() + } + + return CalEvent( + id = id, + url = json.strOrNull("url") ?: "", + title = title, + startDate = start, + endDate = end, + isAllDay = isAllDay, + location = json.strOrNull("location") ?: "", + notes = json.strOrNull("description") ?: "", + color = json.strOrNull("color"), + calendarId = calendarId, + calendarName = json.strOrNull("calendar_name") ?: "", + calendarColor = json.strOrNull("calendarColor") ?: "", + source = json.strOrNull("source") ?: "local", + creator = personFrom(json, "creator"), + isPrivate = json.optBoolean("private", false), + owner = personFrom(json, "owner"), + isGroupEvent = json.optBoolean("is_group_event", false), + displayColor = json.strOrNull("display_color"), + displayTitle = json.strOrNull("display_title"), + reminders = json.optJSONArray("reminders")?.let { arr -> + (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() } + } ?: emptyList(), + readOnly = json.optBoolean("read_only", false), + isBirthday = json.optBoolean("is_birthday", false), + ) + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt new file mode 100644 index 0000000..cb67305 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt @@ -0,0 +1,31 @@ +package com.scarriffle.calendarr.domain.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector + +enum class CalViewType(val key: String) { + MONTH("month"), + WEEK("week"), + DAY("day"), + QUARTER("quarter"), + AGENDA("agenda"); + + val icon: ImageVector + get() = when (this) { + MONTH -> Icons.Filled.CalendarMonth + WEEK -> Icons.Filled.CalendarViewWeek + DAY -> Icons.Filled.WbSunny + QUARTER -> Icons.Filled.DateRange + AGENDA -> Icons.Filled.ViewList + } + + companion object { + fun fromKey(key: String?): CalViewType = + entries.firstOrNull { it.key == key } ?: MONTH + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt new file mode 100644 index 0000000..a0468b0 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt @@ -0,0 +1,27 @@ +package com.scarriffle.calendarr.domain.model + +/** Lightweight user from /api/users/directory (for sharing/group pickers). */ +data class DirectoryUser(val id: Int, val displayName: String) + +/** A share entry on a local calendar. */ +data class CalendarShareEntry(val userId: Int, val displayName: String, val permission: String) + +/** A member of a group, with their server-defined colour. */ +data class GroupMember( + val id: Int, + val displayName: String, + val role: String, + val color: String? = null, +) + +/** A user group + its shared group calendar. */ +data class Group( + val id: Int, + val name: String, + val icon: String? = null, + val role: String, + val memberCount: Int, + val groupCalendarId: Int?, + val groupCalendarColor: String? = null, + val members: List, +) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt new file mode 100644 index 0000000..7bcae1a --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt @@ -0,0 +1,13 @@ +package com.scarriffle.calendarr.domain.model + +/** + * One identity provider configured on the Calendarr server, as returned by + * `GET /api/auth/oidc/providers`. + */ +data class OidcProvider( + val key: String, + val name: String, + val issuer: String, + val scopes: String, + val mobileClientId: String?, +) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt new file mode 100644 index 0000000..fd0a9f7 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt @@ -0,0 +1,29 @@ +package com.scarriffle.calendarr.domain.model + +/** + * Reminder offsets are stored as minutes-before-start integers (0 = at start). + * A few quick presets are offered; anything else is entered as a custom + * number + unit. Mirrors the iOS `ReminderOptions`. + */ +object ReminderOptions { + /** Quick presets: at start, 30 min, 1 day. */ + val presets = listOf(0, 30, 1440) + + /** Default for a freshly-switched custom row (deliberately not a preset). */ + const val customDefault = 120 + + enum class Unit(val mult: Int, val labelKey: String) { + MINUTES(1, "event.reminder_unit.minutes"), + HOURS(60, "event.reminder_unit.hours"), + DAYS(1440, "event.reminder_unit.days"), + WEEKS(10080, "event.reminder_unit.weeks"), + } + + /** Split a minutes value into the largest exact {value, unit} for the custom picker. */ + fun split(minutes: Int): Pair { + for (u in Unit.values().reversed()) { + if (minutes > 0 && minutes % u.mult == 0) return (minutes / u.mult) to u + } + return maxOf(1, minutes) to Unit.MINUTES + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt b/android/app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt new file mode 100644 index 0000000..a905bbc --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt @@ -0,0 +1,111 @@ +package com.scarriffle.calendarr.notifications + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.calendar.calendarKey + +/** + * Schedules OS reminder notifications for upcoming events via AlarmManager. + * Per-event reminders take precedence; otherwise the user's default reminder + * applies. Calendars the user muted (`disabledKeys`) are skipped — their + * reminders are kept on the events, just never fired. Mirrors the iOS + * `NotificationScheduler`. + */ +object NotificationScheduler { + const val CHANNEL_ID = "calendarr_reminders" + const val EXTRA_TITLE = "title" + const val EXTRA_BODY = "body" + const val EXTRA_ID = "id" + + private const val PREFS = "calendarr_reminders_sched" + private const val KEY_COUNT = "scheduled_count" + private const val MAX = 50 // keep the alarm count bounded + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val mgr = context.getSystemService(NotificationManager::class.java) + if (mgr.getNotificationChannel(CHANNEL_ID) == null) { + mgr.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Reminders", NotificationManager.IMPORTANCE_HIGH) + ) + } + } + } + + private data class Pending(val fire: Long, val title: String, val body: String) + + fun reschedule( + context: Context, + events: List, + disabledKeys: Set, + defaultMinutes: Int, + ) { + ensureChannel(context) + val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val now = System.currentTimeMillis() + + val pending = mutableListOf() + for (ev in events) { + if (disabledKeys.contains(calendarKey(ev.source, ev.calendarId))) continue + val offsets = if (ev.reminders.isEmpty()) { + if (defaultMinutes >= 0) listOf(defaultMinutes) else emptyList() + } else ev.reminders + for (m in offsets) { + val fire = ev.startDate.toEpochMilli() - m * 60_000L + if (fire > now) { + val rel = relativeText(m) + val body = if (ev.location.isNotBlank()) "$rel · ${ev.location}" else rel + pending.add(Pending(fire, ev.title, body)) + } + } + } + pending.sortBy { it.fire } + val limited = pending.take(MAX) + + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val lastCount = prefs.getInt(KEY_COUNT, 0) + // Cancel every alarm from the previous run (extras are ignored when + // matching a PendingIntent, so a bare intent with the same code cancels). + for (i in 0 until maxOf(lastCount, limited.size)) { + am.cancel(intentFor(context, i, null)) + } + limited.forEachIndexed { i, p -> + val pi = intentFor(context, i, p) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, p.fire, pi) + } else { + am.set(AlarmManager.RTC_WAKEUP, p.fire, pi) + } + } + prefs.edit().putInt(KEY_COUNT, limited.size).apply() + } + + private fun relativeText(minutes: Int): String = when { + minutes < 60 -> "in $minutes Min." + minutes == 60 -> "in 1 Std." + minutes < 1440 -> "in ${minutes / 60} Std." + minutes == 1440 -> "morgen" + else -> "in ${minutes / 1440} Tagen" + } + + private fun intentFor(context: Context, code: Int, p: Pending?): PendingIntent { + val intent = Intent(context, ReminderReceiver::class.java).apply { + // Distinct action per code so PendingIntents don't collapse together. + action = "com.scarriffle.calendarr.REMINDER_$code" + if (p != null) { + putExtra(EXTRA_ID, code) + putExtra(EXTRA_TITLE, p.title) + putExtra(EXTRA_BODY, p.body) + } + } + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(context, code, intent, flags) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt b/android/app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt new file mode 100644 index 0000000..17b39ba --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt @@ -0,0 +1,33 @@ +package com.scarriffle.calendarr.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.scarriffle.calendarr.R + +/** Posts the reminder notification when an AlarmManager alarm fires. */ +class ReminderReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + NotificationScheduler.ensureChannel(context) + val title = intent.getStringExtra(NotificationScheduler.EXTRA_TITLE) ?: return + val body = intent.getStringExtra(NotificationScheduler.EXTRA_BODY) ?: "" + val id = intent.getIntExtra(NotificationScheduler.EXTRA_ID, 0) + + val notification = NotificationCompat.Builder(context, NotificationScheduler.CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + // notify() is a no-op (and may throw on some OEMs) without the runtime + // POST_NOTIFICATIONS permission; ignore that case. + try { + NotificationManagerCompat.from(context).notify(id, notification) + } catch (_: SecurityException) { + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt new file mode 100644 index 0000000..b2f4f3f --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -0,0 +1,80 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.auth.LoginScreen +import com.scarriffle.calendarr.ui.auth.ServerSetupScreen +import com.scarriffle.calendarr.ui.calendar.CalendarScreen +import com.scarriffle.calendarr.ui.calendar.CalendarViewModel +import com.scarriffle.calendarr.ui.theme.CalendarrTheme +import kotlinx.coroutines.delay + +@Composable +fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { + val route by vm.route.collectAsState() + val settings by vm.settings.collectAsState() + + CalendarrTheme(settings) { + CompositionLocalProvider( + LocalLang provides L10n.resolved(settings.language), + LocalAppSettings provides settings, + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + // Obtain the calendar VM early when logged in so events load + // *behind* the splash; the branded splash stays until ready. + val calendarVm: CalendarViewModel? = + if (route == AppRoute.MAIN) hiltViewModel() else null + val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true + + var minElapsed by remember { mutableStateOf(false) } + var timedOut by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(500); minElapsed = true } + LaunchedEffect(Unit) { delay(6000); timedOut = true } + + if (!minElapsed || (!dataReady && !timedOut)) { + SplashScreen() + return@Surface + } + + when (route) { + // Auth screens draw full-screen; inset them from the system + // bars (edge-to-edge) so their content isn't under status/nav. + AppRoute.SETUP -> Box(Modifier.systemBarsPadding()) { + ServerSetupScreen(onConfigured = vm::onServerConfigured) + } + AppRoute.LOGIN -> Box(Modifier.systemBarsPadding()) { + LoginScreen( + serverUrl = vm.serverUrl, + onLoggedIn = vm::onLoggedIn, + onBack = vm::switchServer, + ) + } + AppRoute.MAIN -> calendarVm?.let { cvm -> + CalendarScreen( + vm = cvm, + onLogout = vm::logout, + onSwitchServer = vm::switchServer, + onSettingsChanged = vm::applyLocalSettings, + onSettingsSynced = vm::refreshSettings, + username = vm.username, + serverUrl = vm.serverUrl, + ) + } ?: SplashScreen() + } + } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt new file mode 100644 index 0000000..2436794 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -0,0 +1,426 @@ +package com.scarriffle.calendarr.ui + +import java.util.Locale + +/** + * In-app localization mirroring the iOS `L10n`. The stored language may be + * "de", "en" or "system" (resolve to device language). + */ +object L10n { + + fun resolved(stored: String): String { + if (stored == "de" || stored == "en") return stored + val pref = Locale.getDefault().language.lowercase() + return if (pref.startsWith("de")) "de" else "en" + } + + fun locale(stored: String): Locale = Locale(resolved(stored)) + + fun t(key: String, stored: String): String { + val lang = resolved(stored) + return strings[lang]?.get(key) ?: strings["en"]?.get(key) ?: key + } + + fun t(key: String, stored: String, vararg args: Any): String { + val template = t(key, stored) + // Support both %@ (iOS) and %s / %d (Java) placeholders. + val javaTemplate = template.replace("%@", "%s") + return runCatching { String.format(locale(stored), javaTemplate, *args) } + .getOrDefault(template) + } + + private val strings: Map> = mapOf( + "de" to mapOf( + "nav.today" to "Heute", "nav.menu" to "Menü", "nav.done" to "Fertig", + "view.month" to "Monat", "view.week" to "Woche", "view.day" to "Tag", + "view.quarter" to "Quartal", "view.agenda" to "Termine", "view.change" to "Ansicht", + "cal.cw" to "KW", "cal.allday" to "Ganztägig", + "cal.no_events_title" to "Keine Termine", + "cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.", + "cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis", + "cal.show_in_day_view" to "In Tagesansicht öffnen", + "cal.show_in_week_view" to "In Wochenansicht öffnen", + "cal.no_events_day" to "Keine Termine an diesem Tag", + "menu.section.settings" to "Einstellungen", "menu.profile" to "Profil", + "menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender", + "menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin", + "menu.sync" to "Mit Server synchronisieren", "menu.sync.section" to "Synchronisierung", + "settings.title" to "Darstellung", "settings.loading" to "Lade Einstellungen…", + "settings.save" to "Speichern", "settings.saved" to "Gespeichert", + "settings.cache.title" to "Vorladen", "settings.cache.range" to "Zeitraum", + "settings.cache.1m" to "±1 Monat", "settings.cache.3m" to "±3 Monate", + "settings.cache.6m" to "±6 Monate", "settings.cache.1y" to "±1 Jahr", + "settings.language" to "Sprache", "lang.system" to "Systemstandard", + "lang.german" to "Deutsch", "lang.english" to "English", + "settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe", + "settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe", + "settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel", + "settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe", + "settings.color.line" to "Linienfarbe", + "settings.sync_all" to "Alle synchronisieren", + "settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen", + "settings.sync_this" to "Zwischen Geräten synchronisieren", + "settings.reset" to "Zurücksetzen", + "settings.appearance" to "Ansicht", + "settings.device" to "Nur auf diesem Gerät", + "settings.more" to "Mehr", + "settings.hide_menu_button" to "Menü-Button ausblenden", + "settings.month_mode" to "Monatswechsel", + "settings.month_mode.scroll" to "Fortlaufend scrollen", + "settings.month_mode.paged" to "Seitenweise blättern", + "settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", + "settings.directory_hidden" to "Profil verbergen", + "settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.", + "settings.defaultreminder" to "Standard-Erinnerung", + "reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit", + "reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher", + "settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast", + "settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel", + "settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum", + "settings.linecontrast.barely" to "Kaum", "settings.linecontrast.subtle" to "Subtil", + "settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Stark", + "settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht", + "settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag", + "settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen", + "settings.month_paged" to "Monatsansicht seitenweise wischen", + "settings.hourheight" to "Stundenhöhe", + "settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal", + "settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross", + "common.cancel" to "Abbrechen", "common.close" to "Schliessen", + "common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen", + "common.save" to "Sichern", "common.retry" to "Erneut versuchen", + "server.title" to "Server", "server.connected" to "Verbundener Server", + "server.switch" to "Server wechseln", "server.logout_title" to "Abmelden", + "server.version" to "Version", + "profile.title" to "Profil", "profile.loading" to "Lade Profil…", + "profile.account" to "Konto", "profile.username" to "Benutzername", + "profile.role" to "Rolle", "profile.role.admin" to "Administrator", + "profile.role.user" to "Benutzer", "profile.email" to "E-Mail", + "profile.no_email" to "Keine E-Mail", "profile.save_email" to "E-Mail speichern", + "profile.email_saved" to "E-Mail gespeichert", "profile.change_password" to "Passwort ändern", + "profile.current_password" to "Aktuelles Passwort", "profile.new_password" to "Neues Passwort", + "profile.new_password_repeat" to "Neues Passwort wiederholen", + "profile.password_mismatch" to "Passwörter stimmen nicht überein", + "profile.password_changed" to "Passwort geändert", + "profile.twofa" to "Zwei-Faktor-Authentifizierung", + "profile.twofa.active" to "2FA ist aktiviert", "profile.twofa.inactive" to "2FA ist deaktiviert", + "profile.twofa.enable" to "2FA einrichten", "profile.twofa.disable" to "2FA deaktivieren", + "twofa.setup_title" to "2FA einrichten", + "twofa.scan_hint" to "Scanne den QR-Code mit deiner Authenticator-App.", + "twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren", + "twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort", + "twofa.disable" to "Deaktivieren", + "event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", "event.private" to "Privat", "event.created_by" to "Erstellt von", + "event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort", + "event.description" to "Beschreibung", "event.calendar_section" to "Kalender", + "event.no_writable" to "Keine beschreibbaren Kalender vorhanden", + "event.calendar_picker" to "Kalender", "event.color_section" to "Farbe", + "event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen", + "event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin", + "event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren", + "event.detail_title" to "Termin", "event.source" to "Quelle", + "event.save" to "Sichern", "event.add" to "Hinzufügen", + "event.delete_confirm" to "Diesen Termin löschen?", + "event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen", + "event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit", + "event.reminder_before" to "vorher", + "event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden", + "event.reminder_unit.days" to "Tage", "event.reminder_unit.weeks" to "Wochen", + "event.reminders_disabled" to "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.", + "settings.default_duration" to "Standard-Termindauer", + "filter.reminders_on" to "Benachrichtigungen aktivieren", "filter.reminders_off" to "Benachrichtigungen deaktivieren", + "accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", + "accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-Konten", + "accounts.caldav.add" to "CalDAV hinzufügen", "accounts.local.header" to "Lokale Kalender", + "accounts.local.empty" to "Keine lokalen Kalender", "accounts.local.add" to "Lokalen Kalender erstellen", + "accounts.ical.header" to "iCal-Abonnements", "accounts.ical.empty" to "Keine Abonnements", + "accounts.ical.add" to "iCal-URL abonnieren", "accounts.google.header" to "Google-Konten", + "accounts.google.empty" to "Keine Google-Konten", + "accounts.google.hint" to "Google-Konten werden über den Browser verknüpft", + "accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "Keine Home Assistant-Konten", + "accounts.ha.add" to "Home Assistant hinzufügen", + "filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden", + "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", + "filter.sort" to "Sortieren", "filter.done" to "Fertig", + "filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten", + "filter.button" to "Kalender ein-/ausblenden", + "filter.sync_error" to "Synchronisierung fehlgeschlagen", + "filter.banish" to "Dauerhaft ausblenden", + "filter.read_only" to "Nur lesen", + "filter.banished_footer" to "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.", + "accounts.banished_header" to "Ausgeblendete Kalender", + "accounts.banished_unhide" to "Wieder einblenden", + "accounts.banished_unknown" to "Unbekannter Kalender", + "caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL", + "caldav.username" to "Benutzername", "caldav.password" to "Passwort", + "caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", + "local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe", + "local.create" to "Erstellen", + "birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen", + "birthday.calendar_name" to "Geburtstage", + "birthday.activate" to "Geburtstagskalender aktivieren", + "birthday.import_contacts" to "Aus Kontakten importieren", + "birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.", + "birthday.is_calendar" to "Geburtstagskalender", + "birthday.person" to "Name", "birthday.person_ph" to "Name der Person", + "birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt", + "birthday.target" to "Geburtstagskalender", + "birthday.no_calendars" to "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender\".", + "birthday.notify" to "Erinnerung", "birthday.notify.off" to "Aus", + "birthday.notify.same_day" to "Am Tag", "birthday.notify.one_day" to "1 Tag vorher", + "birthday.notify.days" to "%d Tage vorher", + "birthday.created" to "Geburtstag „%s\" hinzugefügt", + "ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL", + "ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren", + "ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.", + "ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich", + "ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)", + "ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden", + // Collaboration: profile / privacy / sharing / groups / import-export + "settings.nav.profile" to "Profil", + "profile.display_name" to "Anzeigename", "profile.login_name" to "Anmeldename", + "settings.privacy" to "Privatsphäre", "settings.private_visibility" to "Private Termine", + "settings.private.busy" to "Als beschäftigt zeigen", "settings.private.hidden" to "Verbergen", + "settings.private_visibility.desc" to "Legt fest, wie deine privaten Termine anderen Gruppenmitgliedern erscheinen.", + "settings.group_visible" to "Geteilter Kalender", + "settings.group_visible.desc" to "Dieser Kalender wird in der Gruppenansicht für andere sichtbar.", + "group.visible.none" to "Keiner", + "share.title" to "Teilen", "share.with" to "Geteilt mit", "share.add" to "Person hinzufügen", + "share.permission.read" to "Lesen", "share.permission.read_write" to "Bearbeiten", + "share.none" to "Noch nicht geteilt", "share.search" to "Benutzer suchen", + "accounts.shared.header" to "Kalender von anderen", "accounts.shared_by" to "geteilt von %@", + "accounts.color" to "Farbe ändern", "accounts.action.share" to "Teilen", + "accounts.action.import" to "Importieren", "accounts.action.export" to "Exportieren", + "import.title" to "iCal importieren", "import.result" to "%d importiert, %d übersprungen", + "import.failed" to "Import fehlgeschlagen", "export.failed" to "Export fehlgeschlagen", + "menu.groups" to "Gruppen", "groups.title" to "Gruppen", "groups.empty" to "Noch keine Gruppen", + "groups.create" to "Gruppe erstellen", "groups.name" to "Gruppenname", + "groups.members" to "Mitglieder", "groups.member_count" to "%d Mitglieder", + "groups.icon" to "Symbol", "groups.manage" to "Verwalten", + "groups.delete" to "Gruppe löschen", "groups.delete_confirm" to "Diese Gruppe löschen?", + "groups.leave" to "Gruppe verlassen", "groups.member_color" to "Farbe", + "groups.add_member" to "Mitglied hinzufügen", "groups.view" to "Gruppenansicht", + "groups.calendar" to "Gruppenkalender", "group.event" to "Gruppentermin", + "group.switch.personal" to "Mein Kalender", + // Auth screens + "auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL", + "auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.", + "auth.continue" to "Weiter", "auth.checking" to "Verbinde…", + "auth.login_title" to "Anmelden", "auth.username" to "Benutzername", + "auth.password" to "Passwort", "auth.login" to "Anmelden", + "auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben", + "auth.back" to "Zurück", + // Single sign-on (OpenID Connect) + "sso.or" to "oder", "sso.login_with" to "Mit %s anmelden", + "sso.err.generic" to "Anmeldung fehlgeschlagen", + "sso.err.oidc_failed" to "Anmeldung fehlgeschlagen", + "sso.err.oidc_provider_unreachable" to "Anmeldedienst nicht erreichbar", + "sso.err.oidc_no_browser" to "Kein Browser installiert — für die Anmeldung wird einer benötigt", + "sso.err.oidc_account_not_linked" to "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.", + "sso.err.oidc_signup_disabled" to "Automatische Kontoerstellung ist deaktiviert", + "sso.err.oidc_domain_not_allowed" to "Diese E-Mail-Domain ist nicht zugelassen", + "sso.err.oidc_email_conflict" to "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto", + "sso.err.oidc_email_not_verified" to "E-Mail-Adresse beim Anbieter nicht bestätigt", + "sso.err.oidc_no_email" to "Der Anbieter hat keine E-Mail-Adresse übermittelt", + "sso.err.oidc_unknown_client" to "Diese App ist beim Anbieter nicht registriert", + "sso.err.oidc_invalid_token" to "Das Anmelde-Token ist ungültig", + "sso.err.oidc_no_id_token" to "Der Anbieter hat kein ID-Token geliefert", + ), + "en" to mapOf( + "nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done", + "view.month" to "Month", "view.week" to "Week", "view.day" to "Day", + "view.quarter" to "Quarter", "view.agenda" to "Agenda", "view.change" to "View", + "cal.cw" to "W", "cal.allday" to "All-day", + "cal.no_events_title" to "No events", + "cal.no_events_body" to "No events in the next 90 days.", + "cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event", + "cal.show_in_day_view" to "Open in day view", + "cal.show_in_week_view" to "Open in week view", + "cal.no_events_day" to "No events on this day", + "menu.section.settings" to "Settings", "menu.profile" to "Profile", + "menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars", + "menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin", + "menu.sync" to "Sync with server", "menu.sync.section" to "Synchronization", + "settings.title" to "Appearance", "settings.loading" to "Loading settings…", + "settings.save" to "Save", "settings.saved" to "Saved", + "settings.cache.title" to "Preloading", "settings.cache.range" to "Range", + "settings.cache.1m" to "±1 month", "settings.cache.3m" to "±3 months", + "settings.cache.6m" to "±6 months", "settings.cache.1y" to "±1 year", + "settings.language" to "Language", "lang.system" to "System default", + "lang.german" to "Deutsch", "lang.english" to "English", + "settings.colors" to "Colors", "settings.color.primary" to "Primary color", + "settings.color.accent" to "Accent color", "settings.color.today" to "Today color", + "settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation", + "settings.color.text" to "Text color", "settings.color.background" to "Background color", + "settings.color.line" to "Line color", + "settings.sync_all" to "Sync all", + "settings.sync_all.desc" to "Share these settings across your devices", + "settings.sync_this" to "Sync across devices", + "settings.reset" to "Reset", + "settings.appearance" to "View", + "settings.device" to "This device only", + "settings.more" to "More", + "settings.hide_menu_button" to "Hide menu button", + "settings.month_mode" to "Month switching", + "settings.month_mode.scroll" to "Continuous scroll", + "settings.month_mode.paged" to "Page by page", + "settings.device.footer" to "These settings apply to this device only and are not synced.", + "settings.directory_hidden" to "Hide profile", + "settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.", + "settings.defaultreminder" to "Default reminder", + "reminder.off" to "Off", "reminder.at_start" to "At start time", + "reminder.1d" to "1 day before", "reminder.1w" to "1 week before", + "settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast", + "settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium", + "settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum", + "settings.linecontrast.barely" to "Barely", "settings.linecontrast.subtle" to "Subtle", + "settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Strong", + "settings.calview" to "Calendar view", "settings.defaultview" to "Default view", + "settings.firstweekday" to "First day of week", "settings.monday" to "Monday", + "settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events", + "settings.month_paged" to "Swipe month view as pages", + "settings.hourheight" to "Hour height", + "settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal", + "settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large", + "common.cancel" to "Cancel", "common.close" to "Close", + "common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete", + "common.save" to "Save", "common.retry" to "Retry", + "server.title" to "Server", "server.connected" to "Connected server", + "server.switch" to "Switch server", "server.logout_title" to "Sign out", + "server.version" to "Version", + "profile.title" to "Profile", "profile.loading" to "Loading profile…", + "profile.account" to "Account", "profile.username" to "Username", + "profile.role" to "Role", "profile.role.admin" to "Administrator", + "profile.role.user" to "User", "profile.email" to "Email", + "profile.no_email" to "No email", "profile.save_email" to "Save email", + "profile.email_saved" to "Email saved", "profile.change_password" to "Change password", + "profile.current_password" to "Current password", "profile.new_password" to "New password", + "profile.new_password_repeat" to "Repeat new password", + "profile.password_mismatch" to "Passwords don't match", + "profile.password_changed" to "Password changed", + "profile.twofa" to "Two-factor authentication", + "profile.twofa.active" to "2FA is enabled", "profile.twofa.inactive" to "2FA is disabled", + "profile.twofa.enable" to "Set up 2FA", "profile.twofa.disable" to "Disable 2FA", + "twofa.setup_title" to "Set up 2FA", + "twofa.scan_hint" to "Scan the QR code with your authenticator app.", + "twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate", + "twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password", + "twofa.disable" to "Disable", + "event.title_placeholder" to "Title", "event.allday" to "All-day", "event.private" to "Private", "event.created_by" to "Created by", + "event.start" to "Start", "event.end" to "End", "event.location" to "Location", + "event.description" to "Description", "event.calendar_section" to "Calendar", + "event.no_writable" to "No writable calendars available", + "event.calendar_picker" to "Calendar", "event.color_section" to "Color", + "event.color" to "Event color", "event.reset_color" to "Reset", + "event.edit_title" to "Edit event", "event.new_title" to "New event", + "event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar", + "event.detail_title" to "Event", "event.source" to "Source", + "event.save" to "Save", "event.add" to "Add", + "event.delete_confirm" to "Delete this event?", + "event.reminders" to "Reminders", "event.reminder_add" to "Add reminder", + "event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time", + "event.reminder_before" to "before", + "event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours", + "event.reminder_unit.days" to "days", "event.reminder_unit.weeks" to "weeks", + "event.reminders_disabled" to "Reminders are disabled for this calendar – they will not fire.", + "settings.default_duration" to "Default event duration", + "filter.reminders_on" to "Enable reminders", "filter.reminders_off" to "Disable reminders", + "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", + "accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV accounts", + "accounts.caldav.add" to "Add CalDAV", "accounts.local.header" to "Local calendars", + "accounts.local.empty" to "No local calendars", "accounts.local.add" to "Create local calendar", + "accounts.ical.header" to "iCal subscriptions", "accounts.ical.empty" to "No subscriptions", + "accounts.ical.add" to "Subscribe to iCal URL", "accounts.google.header" to "Google accounts", + "accounts.google.empty" to "No Google accounts", + "accounts.google.hint" to "Google accounts are linked via the browser", + "accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "No Home Assistant accounts", + "accounts.ha.add" to "Add Home Assistant", + "filter.title" to "Calendars", "filter.empty" to "No calendars available", + "filter.show_all" to "Show all", "filter.hide_all" to "Hide all", + "filter.sort" to "Sort", "filter.done" to "Done", + "filter.move_up" to "Move up", "filter.move_down" to "Move down", + "filter.button" to "Show/hide calendars", + "filter.sync_error" to "Sync failed", + "filter.banish" to "Hide permanently", + "filter.read_only" to "Read-only", + "filter.banished_footer" to "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.", + "accounts.banished_header" to "Hidden calendars", + "accounts.banished_unhide" to "Show again", + "accounts.banished_unknown" to "Unknown calendar", + "caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL", + "caldav.username" to "Username", "caldav.password" to "Password", + "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", + "local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color", + "local.create" to "Create", + "birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday", + "birthday.calendar_name" to "Birthdays", + "birthday.activate" to "Enable birthday calendar", + "birthday.import_contacts" to "Import from contacts", + "birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.", + "birthday.is_calendar" to "Birthday calendar", + "birthday.person" to "Name", "birthday.person_ph" to "Person's name", + "birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown", + "birthday.target" to "Birthday calendar", + "birthday.no_calendars" to "No birthday calendar yet. Create a calendar and enable \"Birthday calendar\" first.", + "birthday.notify" to "Reminder", "birthday.notify.off" to "Off", + "birthday.notify.same_day" to "On the day", "birthday.notify.one_day" to "1 day before", + "birthday.notify.days" to "%d days before", + "birthday.created" to "Birthday \"%s\" added", + "ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL", + "ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe", + "ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min", + "ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily", + "ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)", + "ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect", + // Collaboration: profile / privacy / sharing / groups / import-export + "settings.nav.profile" to "Profile", + "profile.display_name" to "Display name", "profile.login_name" to "Login name", + "settings.privacy" to "Privacy", "settings.private_visibility" to "Private events", + "settings.private.busy" to "Show as busy", "settings.private.hidden" to "Hidden", + "settings.private_visibility.desc" to "Controls how your private events appear to other group members.", + "settings.group_visible" to "Shared calendar", + "settings.group_visible.desc" to "This calendar is shown to others in the group view.", + "group.visible.none" to "None", + "share.title" to "Share", "share.with" to "Shared with", "share.add" to "Add person", + "share.permission.read" to "Read", "share.permission.read_write" to "Read & write", + "share.none" to "Not shared yet", "share.search" to "Search users", + "accounts.shared.header" to "Calendars from others", "accounts.shared_by" to "shared by %@", + "accounts.color" to "Change color", "accounts.action.share" to "Share", + "accounts.action.import" to "Import", "accounts.action.export" to "Export", + "import.title" to "Import iCal", "import.result" to "%d imported, %d skipped", + "import.failed" to "Import failed", "export.failed" to "Export failed", + "menu.groups" to "Groups", "groups.title" to "Groups", "groups.empty" to "No groups yet", + "groups.create" to "Create group", "groups.name" to "Group name", + "groups.members" to "Members", "groups.member_count" to "%d members", + "groups.icon" to "Icon", "groups.manage" to "Manage", + "groups.delete" to "Delete group", "groups.delete_confirm" to "Delete this group?", + "groups.leave" to "Leave group", "groups.member_color" to "Color", + "groups.add_member" to "Add member", "groups.view" to "Group view", + "groups.calendar" to "Group calendar", "group.event" to "Group event", + "group.switch.personal" to "My calendar", + // Auth screens + "auth.server_title" to "Connect server", "auth.server_url" to "Server URL", + "auth.server_hint" to "Enter the address of your Calendarr server.", + "auth.continue" to "Continue", "auth.checking" to "Connecting…", + "auth.login_title" to "Sign in", "auth.username" to "Username", + "auth.password" to "Password", "auth.login" to "Sign in", + "auth.totp" to "2FA code", "auth.remember" to "Stay signed in", + "auth.back" to "Back", + // Single sign-on (OpenID Connect) + "sso.or" to "or", "sso.login_with" to "Sign in with %s", + "sso.err.generic" to "Sign-in failed", + "sso.err.oidc_failed" to "Sign-in failed", + "sso.err.oidc_provider_unreachable" to "Sign-in service unreachable", + "sso.err.oidc_no_browser" to "No browser installed — signing in requires one", + "sso.err.oidc_account_not_linked" to "No linked Calendarr account. Sign in locally and link it in Settings.", + "sso.err.oidc_signup_disabled" to "Automatic account creation is disabled", + "sso.err.oidc_domain_not_allowed" to "This email domain is not allowed", + "sso.err.oidc_email_conflict" to "That email address already belongs to another account", + "sso.err.oidc_email_not_verified" to "Email address not verified at the provider", + "sso.err.oidc_no_email" to "The provider did not supply an email address", + "sso.err.oidc_unknown_client" to "This app is not registered with the provider", + "sso.err.oidc_invalid_token" to "The sign-in token is invalid", + "sso.err.oidc_no_id_token" to "The provider did not return an ID token", + ), + ) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt new file mode 100644 index 0000000..e11c9ff --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt @@ -0,0 +1,22 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import com.scarriffle.calendarr.domain.model.AppSettings + +/** Resolved UI language ("de" / "en"), provided at the root. */ +val LocalLang = compositionLocalOf { "de" } + +/** Current appearance settings, provided at the root. */ +val LocalAppSettings = staticCompositionLocalOf { AppSettings() } + +/** Convenience translation lookup that reads the ambient language. */ +@Composable +@ReadOnlyComposable +fun tr(key: String): String = L10n.t(key, LocalLang.current) + +@Composable +@ReadOnlyComposable +fun tr(key: String, vararg args: Any): String = L10n.t(key, LocalLang.current, *args) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt new file mode 100644 index 0000000..36beed2 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt @@ -0,0 +1,144 @@ +package com.scarriffle.calendarr.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.data.OidcAuthenticator +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class AppRoute { SETUP, LOGIN, MAIN } + +@HiltViewModel +class MainViewModel @Inject constructor( + private val credentialStore: CredentialStore, + private val settingsStore: SettingsStore, + private val repository: CalendarRepository, + private val apiProvider: ApiProvider, + private val oidc: OidcAuthenticator, +) : ViewModel() { + + private val _route = MutableStateFlow(computeRoute()) + val route: StateFlow = _route.asStateFlow() + + private val _settings = MutableStateFlow(settingsStore.loadSettings()) + val settings: StateFlow = _settings.asStateFlow() + + val serverUrl: String get() = credentialStore.serverUrl ?: "" + val username: String get() = credentialStore.username ?: "" + val isAdmin: Boolean get() = credentialStore.isAdmin + + init { + if (_route.value == AppRoute.MAIN) { + refreshSettings() + renewSsoSessionIfNeeded() + } + } + + /** + * Keep an SSO session alive without another trip through the browser. + * + * Calendarr's own token simply expires; what `offline_access` buys is a + * refresh token at the *provider*. So renewal means: fresh ID token from + * the provider, then trade it for a fresh Calendarr token. + * + * Best effort — on any failure the existing token stays in place and the + * user notices nothing until it genuinely expires. + */ + private fun renewSsoSessionIfNeeded() { + val state = credentialStore.oidcState ?: return + val providerKey = credentialStore.oidcProvider ?: return + val clientId = credentialStore.oidcClientId ?: return + val base = credentialStore.serverUrl ?: return + val token = credentialStore.token ?: return + if (!expiresSoon(token)) return + + viewModelScope.launch { + runCatching { + val renewed = oidc.renewIdToken(state) ?: return@runCatching + repository.exchangeOidc( + baseUrl = base, + provider = providerKey, + clientId = clientId, + idToken = renewed.idToken, + accessToken = renewed.accessToken, + nonce = renewed.nonce, + ) + credentialStore.saveOidcSession(renewed.serialisedState, providerKey, clientId) + } + } + } + + private fun computeRoute(): AppRoute = when { + !credentialStore.isConfigured -> AppRoute.SETUP + !credentialStore.isLoggedIn -> AppRoute.LOGIN + else -> AppRoute.MAIN + } + + fun onServerConfigured() { _route.value = computeRoute() } + + fun onLoggedIn() { + _route.value = AppRoute.MAIN + refreshSettings() + } + + fun logout() { + credentialStore.clearToken() + _route.value = computeRoute() + } + + fun switchServer() { + credentialStore.clearAll() + apiProvider.invalidate() + _route.value = computeRoute() + } + + /** Pull settings from the server and merge them with local values honouring + * each setting's sync flag (synced keys take the server value). */ + fun refreshSettings() { + viewModelScope.launch { + runCatching { repository.getSettings() }.onSuccess { s -> + _settings.value = settingsStore.applyServerPull(s) + } + } + } + + fun applyLocalSettings(s: AppSettings) { + settingsStore.saveSettings(s) + _settings.value = s + } + + /** + * True when the Calendarr JWT expires within [RENEW_WITHIN_MS]. + * + * The payload is read without verifying the signature — this only decides + * *whether* to renew, and the server validates the token on every request + * regardless. A token we cannot parse is left alone. + */ + private fun expiresSoon(jwt: String): Boolean { + val parts = jwt.split(".") + if (parts.size != 3) return false + return runCatching { + val payload = android.util.Base64.decode( + parts[1], + android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or + android.util.Base64.NO_WRAP, + ) + val exp = org.json.JSONObject(String(payload)).optLong("exp", 0L) + exp > 0L && (exp * 1000L) - System.currentTimeMillis() < RENEW_WITHIN_MS + }.getOrDefault(false) + } + + private companion object { + /** Renew when less than a day of the Calendarr token's life is left. */ + const val RENEW_WITHIN_MS = 24L * 60 * 60 * 1000 + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt new file mode 100644 index 0000000..d1615c1 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt @@ -0,0 +1,57 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.R + +/** + * Branded startup screen shown right after the system splash hands off (same + * black background + same centred logo, so the logo doesn't jump); the app name + * and copyright fade in. Stays until the first events have loaded. + */ +@Composable +fun SplashScreen() { + // Fade the texts in so the handoff from the (text-less) system splash is smooth. + val textAlpha by animateFloatAsState(targetValue = 1f, animationSpec = tween(350), label = "splashText") + + Box(Modifier.fillMaxSize().background(Color.Black)) { + Text( + "Calendarr", + modifier = Modifier.align(Alignment.TopCenter).padding(top = 110.dp).alpha(textAlpha), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + // Match the system splash icon size (~150dp) so it stays put across the handoff. + Image( + painter = painterResource(R.drawable.ic_splash_logo), + contentDescription = null, + modifier = Modifier.align(Alignment.Center).size(150.dp).clip(RoundedCornerShape(34.dp)), + ) + Text( + "© Scarriffle", + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).alpha(textAlpha), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f), + ) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt new file mode 100644 index 0000000..66f905a --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -0,0 +1,558 @@ +package com.scarriffle.calendarr.ui.accounts + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.People +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.components.ColorPickerDialog +import com.scarriffle.calendarr.ui.components.PasswordField +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +private enum class AddType { LOCAL, CALDAV, ICAL, HA } + +/** What colour the picker dialog is currently editing. */ +private sealed interface ColorTarget { + val current: String + data class Local(val id: Int, override val current: String) : ColorTarget + data class ICal(val id: Int, override val current: String) : ColorTarget + data class Source(val source: String, val id: Int, override val current: String) : ColorTarget +} + +private fun safeFileName(name: String): String { + val cleaned = name.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + return (cleaned.ifBlank { "calendar" }) + ".ics" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + onClose: () -> Unit, + onChanged: () -> Unit, + vm: AccountsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val lang = LocalLang.current + var addDialog by remember { mutableStateOf(null) } + var editingColor by remember { mutableStateOf(null) } + var shareCalId by remember { mutableStateOf(null) } + + // Import: pick a document, read its bytes, upload to importTarget. + var importTarget by remember { mutableStateOf(null) } + val openDoc = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val calId = importTarget + importTarget = null + if (uri != null && calId != null) { + val bytes = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() + if (bytes != null) { + vm.importIcs( + calId, bytes, "import.ics", + result = { imported, skipped -> L10n.t("import.result", lang, imported, skipped) }, + onChanged = onChanged, + ) + } + } + } + + // Export: fetch bytes first, then let the user save them to a chosen location. + var pendingExport by remember { mutableStateOf(null) } + val createDoc = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/calendar")) { uri -> + val bytes = pendingExport + pendingExport = null + if (uri != null && bytes != null) { + runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } } + } + } + + // Calendars permanently hidden ("banished") on the server — offered for + // re-enabling. Derived from the loaded account lists' sidebar_hidden flags. + val banishedCals: List = buildList { + vm.caldav.forEach { acc -> + acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach { + add(BanishedCalendar("caldav", it.id, "${acc.name} – ${it.name}", it.color ?: acc.color)) + } + } + vm.google.forEach { acc -> + acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach { + add(BanishedCalendar("google", it.id, "${acc.email} – ${it.name}", it.color ?: "#4285f4")) + } + } + vm.homeAssistant.forEach { acc -> + acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach { + add(BanishedCalendar("homeassistant", it.id, "${acc.name} – ${it.name}", it.color ?: "#46bdc6")) + } + } + } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text(tr("accounts.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + // Local (incl. shared-with-me and group calendars) + item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } } + if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) } + items(vm.local, key = { "l${it.id}" }) { cal -> + LocalCalendarRow( + cal = cal, + onColor = { editingColor = ColorTarget.Local(cal.id, cal.color) }, + onShare = { shareCalId = cal.id }, + onImport = { importTarget = cal.id; openDoc.launch(arrayOf("*/*")) }, + onExport = { vm.exportIcs(cal.id) { bytes -> pendingExport = bytes; createDoc.launch(safeFileName(cal.name)) } }, + onDelete = { vm.deleteLocal(cal.id, onChanged) }, + ) + } + + // CalDAV + item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } } + if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) } + items(vm.caldav, key = { "c${it.id}" }) { acc -> + Column { + AccountHeaderRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) } + acc.calendars.orEmpty().forEach { cal -> + ChildCalendarRow(cal.name, cal.color ?: acc.color) { + editingColor = ColorTarget.Source("caldav", cal.id, cal.color ?: acc.color) + } + } + } + } + + // iCal + item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } } + if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) } + items(vm.ical, key = { "i${it.id}" }) { sub -> + EditableColorRow(sub.name, sub.color, onColor = { editingColor = ColorTarget.ICal(sub.id, sub.color) }) { + vm.deleteICal(sub.id, onChanged) + } + } + + // Google + item { SectionHeaderNoAdd(tr("accounts.google.header")) } + if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) } + items(vm.google, key = { "g${it.id}" }) { acc -> + Column { + AccountHeaderRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) } + acc.calendars.orEmpty().forEach { cal -> + ChildCalendarRow(cal.name, cal.color ?: "#4285f4") { + editingColor = ColorTarget.Source("google", cal.id, cal.color ?: "#4285f4") + } + } + } + } + + // Home Assistant + item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } } + if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) } + items(vm.homeAssistant, key = { "h${it.id}" }) { acc -> + Column { + AccountHeaderRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) } + acc.calendars.orEmpty().forEach { cal -> + ChildCalendarRow(cal.name, cal.color ?: "#46bdc6") { + editingColor = ColorTarget.Source("homeassistant", cal.id, cal.color ?: "#46bdc6") + } + } + } + } + + // Banished (permanently hidden) calendars — re-enable here. + if (banishedCals.isNotEmpty()) { + item { SectionHeaderNoAdd(tr("accounts.banished_header")) } + items(banishedCals, key = { "b${it.source}${it.id}" }) { cal -> + BanishedCalendarRow(cal.label, cal.color) { vm.unbanishCalendar(cal.source, cal.id, onChanged) } + } + } + + vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } + item { Spacer(Modifier.size(40.dp)) } + } + } + } + + when (addDialog) { + AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color -> + vm.addLocal(name, color, onChanged); addDialog = null + } + AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c -> + vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null + } + AddType.ICAL -> ICalDialog(onDismiss = { addDialog = null }) { n, u, c, r -> + vm.addICal(n, u, c, r, onChanged); addDialog = null + } + AddType.HA -> HADialog(onDismiss = { addDialog = null }) { n, u, t -> + vm.addHA(n, u, t, onChanged); addDialog = null + } + null -> Unit + } + + editingColor?.let { target -> + ColorPickerDialog( + initial = target.current, + title = tr("accounts.color"), + onDismiss = { editingColor = null }, + onConfirm = { hex -> + when (target) { + is ColorTarget.Local -> vm.setLocalColor(target.id, hex, onChanged) + is ColorTarget.ICal -> vm.setICalColor(target.id, hex, onChanged) + is ColorTarget.Source -> vm.setSourceColor(target.source, target.id, hex, onChanged) + } + editingColor = null + }, + ) + } + + shareCalId?.let { id -> + SharingSheet(vm = vm, calendarId = id, onDismiss = { shareCalId = null }) + } + + vm.infoMessage?.let { msg -> + AlertDialog( + onDismissRequest = { vm.clearInfo() }, + confirmButton = { TextButton(onClick = { vm.clearInfo() }) { Text(tr("common.ok")) } }, + text = { Text(msg) }, + ) + } +} + +@Composable +private fun SectionHeader(title: String, addLabel: String, onAdd: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + TextButton(onClick = onAdd) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(4.dp)) + Text(addLabel) + } + } +} + +@Composable +private fun SectionHeaderNoAdd(title: String) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp, bottom = 6.dp)) +} + +@Composable +private fun EmptyRow(text: String) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) +} + +/** Tappable colour swatch. */ +@Composable +private fun ColorDot(hex: String, editable: Boolean, onClick: () -> Unit) { + val base = Modifier.size(if (editable) 18.dp else 14.dp).clip(CircleShape).background(colorFromHex(hex)) + Box(if (editable) base.clickable(onClick = onClick) else base) +} + +/** Account header (CalDAV/Google/HA) with a delete button; colour edited on the child rows. */ +@Composable +private fun AccountHeaderRow(name: String, color: String, onDelete: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color))) + Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } +} + +/** A child calendar of a server-managed account: editable colour + name. */ +@Composable +private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ColorDot(color, editable = true, onClick = onColor) + Text(name, modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyMedium) + } +} + +/** A permanently-hidden ("banished") calendar with a "show again" button. */ +private data class BanishedCalendar(val source: String, val id: Int, val label: String, val color: String) + +@Composable +private fun BanishedCalendarRow(name: String, color: String, onUnbanish: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ColorDot(color, editable = false, onClick = {}) + Text( + name, + modifier = Modifier.weight(1f).padding(start = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onUnbanish) { Text(tr("accounts.banished_unhide")) } + } +} + +/** A simple row (iCal) with editable colour + delete. */ +@Composable +private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + ColorDot(color, editable = true, onClick = onColor) + Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } +} + +/** Local calendar row: colour, group marker, "shared by", and an actions menu. */ +@Composable +private fun LocalCalendarRow( + cal: LocalCalendar, + onColor: () -> Unit, + onShare: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, +) { + var menu by remember { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + // Recipients of a shared calendar may recolour it (their own per-user + // colour); only renaming/other management stays owner-only. + ColorDot(cal.color, editable = true, onClick = onColor) + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(cal.name, style = MaterialTheme.typography.bodyLarge) + if (cal.group) { + Spacer(Modifier.size(6.dp)) + Icon(Icons.Filled.People, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp)) + } + } + val by = cal.sharedBy + if (!cal.owned && by != null) { + Text(tr("accounts.shared_by", by), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = tr("accounts.action.share"), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (cal.owned && !cal.group) { + DropdownMenuItem(text = { Text(tr("accounts.action.share")) }, onClick = { menu = false; onShare() }) + } + if (cal.owned || cal.permission == "read_write") { + DropdownMenuItem(text = { Text(tr("accounts.action.import")) }, onClick = { menu = false; onImport() }) + } + DropdownMenuItem(text = { Text(tr("accounts.action.export")) }, onClick = { menu = false; onExport() }) + if (cal.owned) { + DropdownMenuItem(text = { Text(tr("common.delete")) }, onClick = { menu = false; onDelete() }) + } + } + } + } +} + +/** Manage who a local calendar is shared with (owner only). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -> Unit) { + LaunchedEffect(calendarId) { vm.loadSharing(calendarId) } + var permission by remember { mutableStateOf("read") } + var search by remember { mutableStateOf("") } + val sharedIds = vm.shares.map { it.userId }.toSet() + val candidates = vm.directory.filter { + it.id !in sharedIds && (search.isBlank() || it.displayName.contains(search, ignoreCase = true)) + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()), + ) { + Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(16.dp)) + + Text(tr("share.with"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (vm.shares.isEmpty()) { + Text(tr("share.none"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 6.dp)) + } else { + vm.shares.forEach { s -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(s.displayName, modifier = Modifier.weight(1f)) + Text( + if (s.permission == "read_write") tr("share.permission.read_write") else tr("share.permission.read"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton(onClick = { vm.removeShare(calendarId, s.userId) }) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } + } + } + + Divider(Modifier.padding(vertical = 16.dp)) + + Text(tr("share.add"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = permission == "read", onClick = { permission = "read" }, label = { Text(tr("share.permission.read")) }) + FilterChip(selected = permission == "read_write", onClick = { permission = "read_write" }, label = { Text(tr("share.permission.read_write")) }) + } + Spacer(Modifier.size(8.dp)) + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text(tr("share.search")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(4.dp)) + candidates.forEach { u -> + Row( + Modifier.fillMaxWidth().clickable { vm.addShare(calendarId, u.id, permission) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(u.displayName, modifier = Modifier.weight(1f)) + Icon(Icons.Filled.Add, contentDescription = tr("share.add"), tint = MaterialTheme.colorScheme.primary) + } + } + } + } +} + +// ---- Add dialogs ---- + +@Composable +private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { + var name by remember { mutableStateOf("") } + val color = "#34a853" + FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var user by remember { mutableStateOf("") } + var pass by remember { mutableStateOf("") } + val color = "#4285f4" + FormDialog(tr("accounts.caldav.add"), onDismiss, confirmEnabled = url.isNotBlank() && user.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), user.trim(), pass, color) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("caldav.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("caldav.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(user, { user = it }, label = { Text(tr("caldav.username")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + PasswordField(pass, { pass = it }, label = tr("caldav.password"), modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun ICalDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, Int) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + val color = "#46bdc6" + FormDialog(tr("accounts.ical.add"), onDismiss, confirmEnabled = url.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), color, 60) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("ical.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("ical.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun HADialog(onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var token by remember { mutableStateOf("") } + FormDialog(tr("accounts.ha.add"), onDismiss, confirmEnabled = url.isNotBlank() && token.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), token.trim()) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("ha.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("ha.url_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(token, { token = it }, label = { Text(tr("ha.token")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun FormDialog( + title: String, + onDismiss: () -> Unit, + confirmEnabled: Boolean, + onConfirm: () -> Unit, + content: @Composable () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Column { content() } }, + confirmButton = { TextButton(onClick = onConfirm, enabled = confirmEnabled) { Text(tr("caldav.connect")) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } }, + ) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt new file mode 100644 index 0000000..3654f99 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt @@ -0,0 +1,166 @@ +package com.scarriffle.calendarr.ui.accounts + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.CalendarShareEntry +import com.scarriffle.calendarr.domain.model.DirectoryUser +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountsViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var loading by mutableStateOf(true) + private set + var error by mutableStateOf(null) + private set + + var caldav by mutableStateOf>(emptyList()) + private set + var local by mutableStateOf>(emptyList()) + private set + var ical by mutableStateOf>(emptyList()) + private set + var google by mutableStateOf>(emptyList()) + private set + var homeAssistant by mutableStateOf>(emptyList()) + private set + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + error = null + caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()) + local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()) + ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()) + google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()) + homeAssistant = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()) + loading = false + } + } + + private fun mutate(onChanged: () -> Unit, block: suspend () -> Unit) { + viewModelScope.launch { + runCatching { block() } + .onSuccess { load(); onChanged() } + .onFailure { error = it.message } + } + } + + fun addLocal( + name: String, color: String, onChanged: () -> Unit, + isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null, + ) = mutate(onChanged) { + repository.addLocalCalendar(name, color, isBirthday, birthdayNotifyDaysBefore) + } + + fun deleteLocal(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteLocalCalendar(id) } + + fun addCalDAV(name: String, url: String, user: String, pass: String, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addCalDAVAccount(name, url, user, pass, color) } + + fun deleteCalDAV(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteCalDAVAccount(id) } + + fun addICal(name: String, url: String, color: String, refreshMinutes: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.addICalSubscription(name, url, color, refreshMinutes) } + + fun deleteICal(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteICalSubscription(id) } + + fun addHA(name: String, url: String, token: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addHomeAssistantAccount(name, url, token) } + + fun deleteHA(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteHomeAssistantAccount(id) } + + fun deleteGoogle(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteGoogleAccount(id) } + + // ---- Calendar colour ---- + + fun setLocalColor(id: Int, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.updateLocalCalendarColor(id, color) } + + fun setICalColor(id: Int, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.updateICalColor(id, color) } + + fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) } + + // ---- Banished (permanently hidden) calendars ---- + + /** Lift the server-side sidebar_hidden flag so a banished calendar reappears. + * `onChanged` triggers the calendar screen's reconcile + refetch. */ + fun unbanishCalendar(source: String, calendarId: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.setCalendarSidebarHidden(source, calendarId, hidden = false) } + + // ---- Sharing ---- + + var shares by mutableStateOf>(emptyList()) + private set + var directory by mutableStateOf>(emptyList()) + private set + + fun loadSharing(calendarId: Int) { + viewModelScope.launch { + shares = runCatching { repository.getShares(calendarId) }.getOrDefault(emptyList()) + directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList()) + } + } + + fun addShare(calendarId: Int, userId: Int, permission: String) { + viewModelScope.launch { + runCatching { repository.addShare(calendarId, userId, permission) } + .onSuccess { loadSharing(calendarId) } + .onFailure { error = it.message } + } + } + + fun removeShare(calendarId: Int, userId: Int) { + viewModelScope.launch { + runCatching { repository.removeShare(calendarId, userId) } + .onSuccess { loadSharing(calendarId) } + .onFailure { error = it.message } + } + } + + // ---- Import / export ---- + + var infoMessage by mutableStateOf(null) + private set + + fun clearInfo() { infoMessage = null } + fun clearError() { error = null } + + fun importIcs(calendarId: Int, bytes: ByteArray, filename: String, result: (Int, Int) -> String, onChanged: () -> Unit) { + viewModelScope.launch { + runCatching { repository.importIcsFile(calendarId, bytes, filename) } + .onSuccess { (imported, skipped, _) -> infoMessage = result(imported, skipped); load(); onChanged() } + .onFailure { error = it.message } + } + } + + fun exportIcs(calendarId: Int, onBytes: (ByteArray) -> Unit) { + viewModelScope.launch { + runCatching { repository.exportIcs(calendarId) } + .onSuccess { onBytes(it) } + .onFailure { error = it.message } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..1ce4d22 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt @@ -0,0 +1,222 @@ +package com.scarriffle.calendarr.ui.auth + +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.data.OidcAuthenticator +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.data.remote.OidcException +import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException +import com.scarriffle.calendarr.domain.model.OidcProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationException +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val repository: CalendarRepository, + private val credentialStore: CredentialStore, + private val oidc: OidcAuthenticator, +) : ViewModel() { + + // Server setup + var serverUrl by mutableStateOf(credentialStore.serverUrl ?: "") + private set + var checking by mutableStateOf(false) + private set + var setupError by mutableStateOf(null) + private set + + // Login + var username by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var totpCode by mutableStateOf("") + private set + var showTotp by mutableStateOf(false) + private set + var rememberMe by mutableStateOf(true) + private set + var loggingIn by mutableStateOf(false) + private set + var loginError by mutableStateOf(null) + private set + + // Single sign-on + var ssoProviders by mutableStateOf>(emptyList()) + private set + /** Key of the provider whose flow is running, or null. Per-provider so a + * second button does not also show a spinner. */ + var ssoBusyKey by mutableStateOf(null) + private set + + /** Stable server error slug (e.g. `oidc_account_not_linked`). The screen + * turns it into a message; the ViewModel cannot call the translator. */ + var ssoErrorSlug by mutableStateOf(null) + private set + + private var loadingProviders = false + + + fun onServerUrlChange(v: String) { serverUrl = v; setupError = null } + fun onUsernameChange(v: String) { username = v; loginError = null; ssoErrorSlug = null } + fun onPasswordChange(v: String) { password = v; loginError = null; ssoErrorSlug = null } + fun onTotpChange(v: String) { totpCode = v; loginError = null; ssoErrorSlug = null } + fun onRememberChange(v: Boolean) { rememberMe = v } + + fun checkServer(onConfigured: () -> Unit) { + val url = serverUrl.trim() + if (url.isEmpty()) { setupError = "URL erforderlich"; return } + viewModelScope.launch { + checking = true + setupError = null + runCatching { repository.setupRequired(url) } + .onSuccess { + credentialStore.serverUrl = ApiProvider.normalize(url) + onConfigured() + } + .onFailure { setupError = it.message ?: "Verbindung fehlgeschlagen" } + checking = false + } + } + + /** Load the configured providers. Silently yields an empty list when SSO + * is off or the server is older — the password form stays as it is. */ + fun loadSsoProviders() { + val base = credentialStore.serverUrl ?: return + if (ssoProviders.isNotEmpty() || loadingProviders) return + loadingProviders = true + viewModelScope.launch { + runCatching { repository.oidcProviders(base) } + .onSuccess { ssoProviders = it } + loadingProviders = false + } + } + + /** + * Start an SSO login: fetch the provider's configuration, build the PKCE + * request and open the Custom Tab. + * + * The provider key and client id are persisted before launching, because + * the browser runs in its own task and this process may well be killed + * while it is in the foreground. + */ + fun startSso(provider: OidcProvider, launch: (Intent) -> Unit) { + val clientId = provider.mobileClientId + if (clientId == null) { ssoErrorSlug = "oidc_unknown_client"; return } + viewModelScope.launch { + ssoBusyKey = provider.key + loginError = null + ssoErrorSlug = null + // `launch` has to stay inside runCatching: the result launcher is + // unregistered if the user leaves the screen while discovery is in + // flight, and the resulting IllegalStateException would otherwise + // escape viewModelScope and take the app down. + runCatching { + val config = oidc.discover(provider) + val request = oidc.buildRequest(config, provider) + credentialStore.oidcProvider = provider.key + credentialStore.oidcClientId = clientId + launch(oidc.authorizationIntent(request)) + }.onFailure { e -> + ssoBusyKey = null + ssoErrorSlug = when (e) { + // No browser at all needs a different remedy than "the + // login service is down". + is ActivityNotFoundException -> "oidc_no_browser" + else -> "oidc_provider_unreachable" + } + } + } + } + + /** Handle the browser's return: exchange the code, then trade the ID token. */ + fun finishSso(data: Intent?, onSuccess: () -> Unit) { + if (data == null) { ssoBusyKey = null; return } + + // AppAuth reports a user-cancelled flow as a normal result carrying + // USER_CANCELED_AUTH_FLOW — backing out of the browser is not an error. + val failure = AuthorizationException.fromIntent(data) + if (failure == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW) { + ssoBusyKey = null + return + } + + val providerKey = credentialStore.oidcProvider + val clientId = credentialStore.oidcClientId + if (providerKey == null || clientId == null) { + ssoBusyKey = null + ssoErrorSlug = "oidc_failed" + return + } + + viewModelScope.launch { + runCatching { + val (response, tokens) = oidc.exchangeCode(data) + val idToken = tokens.idToken ?: throw OidcException("oidc_no_id_token") + val result = repository.exchangeOidc( + baseUrl = credentialStore.serverUrl ?: error("no server"), + provider = providerKey, + clientId = clientId, + idToken = idToken, + accessToken = tokens.accessToken, + // AppAuth round-trips the original request through the + // redirect, so the nonce survives process death. + nonce = response.request.nonce, + ) + // Keep the provider's refresh token so the session can be + // renewed later without another trip through the browser. + credentialStore.saveOidcSession( + state = oidc.stateFor(response, tokens), + provider = providerKey, + clientId = clientId, + ) + result + }.onSuccess { + ssoBusyKey = null + onSuccess() + }.onFailure { e -> + ssoBusyKey = null + ssoErrorSlug = if (e is OidcException) e.slug else "oidc_failed" + } + } + } + + fun login(onSuccess: () -> Unit) { + val base = credentialStore.serverUrl ?: return + if (username.isBlank() || password.isBlank()) { + loginError = "Benutzername und Passwort erforderlich" + return + } + viewModelScope.launch { + loggingIn = true + loginError = null + runCatching { + repository.login( + baseUrl = base, + username = username.trim(), + password = password, + totpCode = totpCode.trim().takeIf { it.isNotEmpty() }, + rememberMe = rememberMe, + ) + }.onSuccess { onSuccess() } + .onFailure { e -> + if (e is TwoFactorRequiredException) { + showTotp = true + loginError = null + } else { + loginError = e.message ?: "Anmeldung fehlgeschlagen" + } + } + loggingIn = false + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..afa5d9a --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt @@ -0,0 +1,167 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.Divider +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.ui.components.PasswordField +import com.scarriffle.calendarr.ui.tr + +@Composable +fun LoginScreen( + serverUrl: String, + onLoggedIn: () -> Unit, + onBack: () -> Unit, + vm: AuthViewModel = hiltViewModel(), +) { + // Browser round-trip for SSO. AppAuth returns the authorization response + // as an Activity result, so nothing has to be wired into MainActivity. + val ssoLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> vm.finishSso(result.data, onLoggedIn) } + + LaunchedEffect(Unit) { vm.loadSsoProviders() } + + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = tr("auth.back")) + } + Spacer(Modifier.height(0.dp)) + Text(serverUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(tr("auth.login_title"), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = vm.username, + onValueChange = vm::onUsernameChange, + label = { Text(tr("auth.username")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + PasswordField( + value = vm.password, + onValueChange = vm::onPasswordChange, + label = tr("auth.password"), + modifier = Modifier.fillMaxWidth(), + ) + AnimatedVisibility(vm.showTotp) { + Column { + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = vm.totpCode, + onValueChange = vm::onTotpChange, + label = { Text(tr("auth.totp")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + modifier = Modifier.fillMaxWidth(), + ) + } + } + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(tr("auth.remember"), style = MaterialTheme.typography.bodyMedium) + Switch(checked = vm.rememberMe, onCheckedChange = vm::onRememberChange) + } + vm.loginError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + vm.ssoErrorSlug?.let { slug -> + // Unknown slugs fall through to the generic message rather than + // showing the raw key. + val key = "sso.err.$slug" + val msg = tr(key).takeIf { it != key } ?: tr("sso.err.generic") + Spacer(Modifier.height(8.dp)) + Text(msg, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.login(onLoggedIn) }, + enabled = !vm.loggingIn, + modifier = Modifier.fillMaxWidth(), + ) { + if (vm.loggingIn) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("auth.login")) + } + } + if (vm.ssoProviders.isNotEmpty()) { + Spacer(Modifier.height(20.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Divider(modifier = Modifier.weight(1f)) + Text( + tr("sso.or"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp), + ) + Divider(modifier = Modifier.weight(1f)) + } + Spacer(Modifier.height(12.dp)) + vm.ssoProviders.forEach { provider -> + OutlinedButton( + onClick = { vm.startSso(provider) { intent -> ssoLauncher.launch(intent) } }, + enabled = vm.ssoBusyKey == null && !vm.loggingIn, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) { + if (vm.ssoBusyKey == provider.key) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("sso.login_with", provider.name)) + } + } + } + } + TextButton(onClick = onBack) { Text(tr("server.switch")) } + } + } +} + +/** Read the configured server URL for display. */ +@Composable +fun rememberServerUrl(credentialStore: CredentialStore): String = + credentialStore.serverUrl ?: "" diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt new file mode 100644 index 0000000..e5ec7c9 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt @@ -0,0 +1,88 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.tr + +@Composable +fun ServerSetupScreen( + onConfigured: () -> Unit, + vm: AuthViewModel = hiltViewModel(), +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 28.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Filled.CalendarMonth, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(64.dp), + ) + Spacer(Modifier.height(16.dp)) + Text("Calendarr", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + tr("auth.server_hint"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + OutlinedTextField( + value = vm.serverUrl, + onValueChange = vm::onServerUrlChange, + label = { Text(tr("auth.server_url")) }, + placeholder = { Text("https://cal.example.com") }, + singleLine = true, + isError = vm.setupError != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + modifier = Modifier.fillMaxWidth(), + ) + vm.setupError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.checkServer(onConfigured) }, + enabled = !vm.checking && vm.serverUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (vm.checking) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("auth.continue")) + } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt new file mode 100644 index 0000000..f1b1376 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt @@ -0,0 +1,118 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun AgendaView( + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, +) { + val lang = LocalLang.current + val dimPast = com.scarriffle.calendarr.ui.LocalAppSettings.current.dimPastEvents + val now = java.time.Instant.now() + val today = LocalDate.now() + val days = (0 until 90).map { today.plusDays(it.toLong()) } + .map { it to vm.eventsOn(it, state.events) } + .filter { it.second.isNotEmpty() } + + if (days.isEmpty()) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(tr("cal.no_events_title"), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + tr("cal.no_events_body"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + val headerFmt = DateTimeFormatter.ofPattern("EEEE, d. MMMM", com.scarriffle.calendarr.ui.L10n.locale(lang)) + + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + days.forEach { (date, events) -> + item(key = "h-$date") { + Text( + headerFmt.format(date).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (date == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp), + ) + } + items(events, key = { "${date}-${it.id}" }) { ev -> + AgendaRow(ev, lang, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) + } + } + item { Spacer(Modifier.height(80.dp)) } + } +} + +@Composable +private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .alpha(if (dimmed) 0.45f else 1f) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + EventLabel( + event = event, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.bodyLarge.fontSize, + fontWeight = FontWeight.Medium, + ) + if (event.location.isNotBlank()) { + Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Spacer(Modifier.width(8.dp)) + Text( + eventTimeRange(event, lang), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt new file mode 100644 index 0000000..ea95382 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/BirthdayDialog.kt @@ -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) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarDrawer.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarDrawer.kt new file mode 100644 index 0000000..d77d526 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarDrawer.kt @@ -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, + 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, 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() }, + ) + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt new file mode 100644 index 0000000..167da4b --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -0,0 +1,168 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +data class CalendarFilterEntry(val key: String, val name: String, val color: String, val source: String = "", val readOnly: Boolean = false) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarFilterSheet( + events: List, + vm: CalendarViewModel, + onDismiss: () -> Unit, +) { + val state by vm.state.collectAsState() + val groupMode = state.activeGroup != null + + // In group mode the filter lists members (+ the group calendar) so they can + // be hidden individually, Outlook-style; otherwise the normal calendars. + val groupEntries: List = if (groupMode) { + buildList { + state.activeGroupMembers.forEach { m -> + add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4")) + } + add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4")) + } + } else emptyList() + val rows = if (groupMode) groupEntries else events + val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Row { + TextButton(onClick = { + if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet()) + }) { Text(tr("filter.show_all")) } + TextButton(onClick = { + if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet()) + else vm.setHiddenCalendars(rows.map { it.key }.toSet()) + }) { Text(tr("filter.hide_all")) } + } + } + if (rows.isEmpty()) { + Text( + tr("filter.empty"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp), + ) + } + rows.forEach { entry -> + val visible = entry.key !in hiddenSet + // entry.key is "source:id" (see calendarKey()); match sync errors on the + // same (source, calendarId) pair the server already attaches to events. + val entryId = entry.key.substringAfter(":") + val hasSyncError = !groupMode && state.syncErrors.any { err -> + err.source == entry.source && err.calendarId?.toString() == entryId + } + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color))) + if (hasSyncError) { + Icon( + Icons.Filled.WarningAmber, + contentDescription = tr("filter.sync_error"), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp).size(16.dp), + ) + } + Text( + entry.name, + modifier = Modifier.weight(1f).padding(start = 12.dp), + style = MaterialTheme.typography.bodyLarge, + ) + if (entry.readOnly) { + Icon( + Icons.Filled.Lock, + contentDescription = tr("filter.read_only"), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 4.dp).size(15.dp), + ) + } + if (!groupMode) { + val remDisabled = entry.key in state.reminderDisabledKeys + IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) { + Icon( + if (remDisabled) Icons.Filled.NotificationsOff else Icons.Filled.Notifications, + contentDescription = tr(if (remDisabled) "filter.reminders_on" else "filter.reminders_off"), + tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, + ) + } + // Banish = permanently hide (syncs to the server); moves the + // calendar into Settings, distinct from the local quick-hide. + IconButton(onClick = { vm.setCalendarBanished(entry.key, banished = true) }) { + Icon( + Icons.Filled.Archive, + contentDescription = tr("filter.banish"), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = visible, + onCheckedChange = { + if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it) + else vm.setCalendarHidden(entry.key, !it) + }, + ) + } + } + if (!groupMode && state.banishedKeys.isNotEmpty()) { + Text( + tr("filter.banished_footer"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + Box(Modifier.padding(bottom = 24.dp)) + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt new file mode 100644 index 0000000..81f17d9 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -0,0 +1,122 @@ +package com.scarriffle.calendarr.ui.calendar + +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.L10n +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle + +private val zone: ZoneId = ZoneId.systemDefault() + +/** Localized month name with a hard fallback (guards against empty/numeric results). */ +private fun monthName(date: LocalDate, style: TextStyle, loc: java.util.Locale): String { + val name = date.month.getDisplayName(style, loc) + val safe = if (name.isBlank() || name.all { it.isDigit() }) { + date.month.getDisplayName(TextStyle.FULL, java.util.Locale.ENGLISH) + } else { + name + } + return safe.replaceFirstChar { it.uppercase(loc) } +} + +fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { + val loc = L10n.locale(lang) + return when (viewType) { + CalViewType.MONTH -> { + // Use the FORMAT month style (TextStyle.FULL). The standalone style + // (FULL_STANDALONE / the "LLLL" pattern) drops the name for some + // months under desugared java.time (e.g. December showed only "2026"). + val month = monthName(date, TextStyle.FULL, loc) + "$month ${date.year}" + } + CalViewType.QUARTER -> { + val endM = date.plusMonths(2) + "${monthName(date, TextStyle.SHORT, loc)} ${date.year} – ${monthName(endM, TextStyle.SHORT, loc)} ${endM.year}" + } + CalViewType.WEEK -> { + val start = date + val fmt = DateTimeFormatter.ofPattern("d. MMM", loc) + val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc) + // Two lines for the compact top bar. + "${fmt.format(start)} –\n${endFmt.format(start.plusDays(6))}" + } + CalViewType.DAY -> { + val weekday = DateTimeFormatter.ofPattern("EEEE", loc).format(date) + val rest = DateTimeFormatter.ofPattern("d. MMMM yyyy", loc).format(date) + "$weekday\n$rest" + } + CalViewType.AGENDA -> L10n.t("view.agenda", lang) + } +} + +fun weekdayLabels(mondayFirst: Boolean, lang: String): List { + val loc = L10n.locale(lang) + val order = if (mondayFirst) { + listOf(1, 2, 3, 4, 5, 6, 7) + } else { + listOf(7, 1, 2, 3, 4, 5, 6) + } + return order.map { + java.time.DayOfWeek.of(it).getDisplayName(TextStyle.SHORT, loc).take(2) + } +} + +fun timeLabel(instant: Instant, lang: String): String = + DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant) + +/** Opacity for secondary text (week number, overflow count) per text_contrast 1..4. */ +fun secondaryTextOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) { + 1 -> 0.40f + 2 -> 0.55f + 3 -> 0.72f + else -> 0.92f +} + +/** Opacity for regular grid lines per line_contrast 1..4. */ +fun gridLineOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) { + 1 -> 0.15f + 2 -> 0.30f + 3 -> 0.50f + else -> 0.80f +} + +fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone) + +fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) + +/** Minutes-from-midnight of the instant in the local zone. */ +fun minutesOfDay(instant: Instant): Int { + val t = localTime(instant) + return t.hour * 60 + t.minute +} + +fun eventTimeRange(event: CalEvent, lang: String): String { + if (event.isAllDay) return L10n.t("cal.allday", lang) + return "${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}" +} + +/** Full human-readable date (+time) range for the detail sheet. */ +fun eventDateRange(event: CalEvent, lang: String): String { + val loc = L10n.locale(lang) + val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy", loc) + val startDate = localDate(event.startDate) + val endDate = localDate(event.endDate) + if (event.isAllDay) { + // All-day end is exclusive on the server; show the inclusive last day. + val lastDay = endDate.minusDays(1) + return if (lastDay.isAfter(startDate)) { + "${dateFmt.format(startDate)} – ${dateFmt.format(lastDay)}" + } else { + "${dateFmt.format(startDate)} · ${L10n.t("cal.allday", lang)}" + } + } + return if (startDate == endDate) { + "${dateFmt.format(startDate)}\n${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}" + } else { + "${dateFmt.format(startDate)} ${timeLabel(event.startDate, lang)}\n– ${dateFmt.format(endDate)} ${timeLabel(event.endDate, lang)}" + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt new file mode 100644 index 0000000..eb6560b --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -0,0 +1,590 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Divider +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.accounts.AccountsScreen +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.ui.event.EventDetailScreen +import com.scarriffle.calendarr.ui.event.EventEditorSheet +import com.scarriffle.calendarr.ui.groups.GroupIcon +import com.scarriffle.calendarr.ui.groups.GroupsScreen +import com.scarriffle.calendarr.ui.menu.MenuScreen +import com.scarriffle.calendarr.ui.profile.ProfileScreen +import com.scarriffle.calendarr.ui.settings.SettingsScreen +import com.scarriffle.calendarr.ui.tr +import kotlinx.coroutines.launch +import java.time.LocalDate + +private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS } + +data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun CalendarScreen( + onLogout: () -> Unit, + onSwitchServer: () -> Unit, + onSettingsChanged: (AppSettings) -> Unit, + onSettingsSynced: () -> Unit, + username: String = "", + serverUrl: String = "", + vm: CalendarViewModel = hiltViewModel(), +) { + val state by vm.state.collectAsState() + val lang = LocalLang.current + val context = androidx.compose.ui.platform.LocalContext.current + + // Ask once for notification permission (Android 13+), then keep the OS + // reminder alarms in sync with the visible events / muted-calendar set. + val notifPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) {} + androidx.compose.runtime.LaunchedEffect(Unit) { + if (android.os.Build.VERSION.SDK_INT >= 33 && + androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.POST_NOTIFICATIONS + ) != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } + androidx.compose.runtime.LaunchedEffect(state.events, state.reminderDisabledKeys) { + com.scarriffle.calendarr.notifications.NotificationScheduler.reschedule( + context, state.events, state.reminderDisabledKeys, vm.defaultReminderMinutes + ) + } + // Re-check server-side calendar visibility whenever the app returns to the + // foreground (e.g. after hiding/showing a calendar on the web or another + // device); reloads only if something changed. + val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) vm.onAppResumed() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + // Contacts birthday import (mirrors iOS): request permission on demand; sync + // this device's contact birthdays into the birthday calendar. + val deviceName = android.os.Build.MODEL ?: "Android" + fun runBirthdayImport() { + vm.birthdaysSyncEnabled = true + vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName) + } + val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) { granted -> if (granted) runBirthdayImport() } + fun startBirthdayImport() { + if (androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.READ_CONTACTS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS) + } + androidx.compose.runtime.LaunchedEffect(Unit) { + if (vm.birthdaysSyncEnabled && + androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.READ_CONTACTS + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) runBirthdayImport() + } + + var viewMenuOpen by remember { mutableStateOf(false) } + var detailEvent by remember { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var overlay by remember { mutableStateOf(Overlay.NONE) } + var dayPreview by remember { mutableStateOf(null) } + var fabMenuOpen by remember { mutableStateOf(false) } + var showBirthday by remember { mutableStateOf(false) } + var birthdayCal by remember { mutableStateOf(null) } + val birthdayScope = rememberCoroutineScope() + val birthdayCalName = tr("birthday.calendar_name") + + // Side navigation drawer (calendars + groups + view + menu). + val drawerState = rememberDrawerState(DrawerValue.Closed) + val drawerScope = rememberCoroutineScope() + val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) } + androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) { + if (drawerState.isOpen) vm.loadAllCalendars() + } + + // Continuous month scrolling + val monthListState = rememberLazyListState() + var todaySignal by remember { mutableIntStateOf(0) } + var monthJumpSignal by remember { mutableIntStateOf(0) } + var monthJumpTarget by remember { mutableStateOf(null) } + var visibleMonth by remember { mutableStateOf(state.currentDate) } + val isMonth = state.viewType == CalViewType.MONTH + val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang) + else titleForView(state.viewType, state.currentDate, lang) + + fun goPrev() { + if (isMonth) { monthJumpTarget = visibleMonth.minusMonths(1); monthJumpSignal++ } else vm.navigatePrev() + } + fun goNext() { + if (isMonth) { monthJumpTarget = visibleMonth.plusMonths(1); monthJumpSignal++ } else vm.navigateNext() + } + fun goToday() { + if (isMonth) todaySignal++ else vm.moveToToday() + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + CalendarDrawerContent( + state = state, vm = vm, username = username, serverUrl = serverUrl, + calendars = drawerCalendars, + onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU }, + onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() }, + onClose = { drawerScope.launch { drawerState.close() } }, + ) + }, + ) { + Scaffold( + topBar = { + CompactTopBar( + title = barTitle, + viewType = state.viewType, + loading = state.isLoading || state.isBackgroundCaching, + viewMenuOpen = viewMenuOpen, + showMenuButton = !state.hideMenuButton, + onMenu = { drawerScope.launch { drawerState.open() } }, + onPrev = { goPrev() }, + onToday = { goToday() }, + onNext = { goNext() }, + onViewMenuToggle = { viewMenuOpen = it }, + onSelectView = { vm.setViewType(it) }, + ) + }, + floatingActionButton = { + // Tap = new event; long-press opens a small menu (new event / new + // birthday). A transparent overlay carries the combined click so the + // long-press is reliable on the Material FAB. + Box(Modifier.navigationBarsPadding()) { + FloatingActionButton( + onClick = {}, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) + } + Box( + Modifier + .matchParentSize() + .clip(CircleShape) + .combinedClickable( + onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + onLongClick = { fabMenuOpen = true }, + ), + ) + DropdownMenu(expanded = fabMenuOpen, onDismissRequest = { fabMenuOpen = false }) { + DropdownMenuItem( + text = { Text(tr("cal.new_event")) }, + onClick = { fabMenuOpen = false; editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + ) + DropdownMenuItem( + text = { Text(tr("birthday.new")) }, + onClick = { fabMenuOpen = false; showBirthday = true }, + ) + } + } + }, + // Edge-to-edge: we place the system-bar insets ourselves (top bar gets + // statusBarsPadding, the content column gets navigationBarsPadding) so + // nothing hides behind the status/nav bars. + contentWindowInsets = WindowInsets(0), + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) { + state.error?.let { err -> + ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) + } + if (state.syncErrors.isNotEmpty()) { + SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors) + } + state.activeGroup?.let { g -> + GroupBanner(group = g, onExit = { vm.switchGroup(null) }) + } + Box(Modifier.fillMaxSize()) { + // remember: stable callbacks keep the lazily composed week rows + // skippable during scroll (fresh lambdas would recompose them all). + CalendarBody( + state = state, + vm = vm, + monthListState = monthListState, + scrollToTodaySignal = todaySignal, + monthJumpSignal = monthJumpSignal, + monthJumpTarget = monthJumpTarget, + onVisibleMonthChange = remember { { visibleMonth = it } }, + onEventClick = remember { { detailEvent = it } }, + onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } }, + onDayLongPress = remember { { date -> dayPreview = date } }, + ) + } + } + } + } + + // ---- Sheets ---- + + dayPreview?.let { date -> + DayPreviewDialog( + date = date, + events = remember(state.events, date) { vm.eventsOn(date, state.events) }, + onDismiss = { dayPreview = null }, + onEventClick = { ev -> dayPreview = null; detailEvent = ev }, + onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) }, + onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) }, + onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) }, + ) + } + + if (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. + var lastDetail by remember { mutableStateOf(null) } + detailEvent?.let { lastDetail = it } + AnimatedVisibility( + visible = detailEvent != null, + enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(), + ) { + val ev = lastDetail + if (ev != null) { + EventDetailScreen( + event = ev, + currentUserId = vm.currentUserId, + onClose = { detailEvent = null }, + onEdit = { + detailEvent = null + editor = EditorRequest(ev, localDate(ev.startDate)) + }, + onCopy = { + detailEvent = null + editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) + }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, + ) + } + } + + editor?.let { req -> + EventEditorSheet( + request = req, + writableCalendars = state.writableCalendars, + onDismiss = { editor = null }, + defaultDurationMinutes = vm.defaultEventDurationMinutes, + reminderDisabledKeys = state.reminderDisabledKeys, + onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders -> + vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error -> + if (error == null) editor = null + } + }, + ) + } + + when (overlay) { + Overlay.MENU -> MenuScreen( + username = username, + serverUrl = serverUrl, + onClose = { overlay = Overlay.NONE }, + onProfile = { overlay = Overlay.PROFILE }, + onAppearance = { overlay = Overlay.SETTINGS }, + onAccounts = { overlay = Overlay.ACCOUNTS }, + onGroups = { overlay = Overlay.GROUPS }, + onSync = { overlay = Overlay.NONE; vm.syncWithServer() }, + onSwitchServer = onSwitchServer, + onLogout = onLogout, + ) + Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.MENU }) + Overlay.SETTINGS -> SettingsScreen( + onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() }, + onSettingsChanged = onSettingsChanged, + onSettingsSynced = onSettingsSynced, + ) + Overlay.ACCOUNTS -> AccountsScreen( + onClose = { overlay = Overlay.MENU }, + onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, + ) + Overlay.GROUPS -> GroupsScreen( + onClose = { overlay = Overlay.MENU }, + onChanged = { vm.loadGroups(); vm.loadWritableCalendars() }, + onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) }, + ) + Overlay.NONE -> Unit + } +} + +@Composable +private fun CalendarBody( + state: CalendarUiState, + vm: CalendarViewModel, + monthListState: androidx.compose.foundation.lazy.LazyListState, + scrollToTodaySignal: Int, + monthJumpSignal: Int, + monthJumpTarget: LocalDate?, + onVisibleMonthChange: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, + onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, +) { + when (state.viewType) { + CalViewType.MONTH -> MonthView( + state = state, + vm = vm, + listState = monthListState, + scrollToTodaySignal = scrollToTodaySignal, + monthJumpSignal = monthJumpSignal, + monthJumpTarget = monthJumpTarget, + onVisibleMonthChange = onVisibleMonthChange, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) + CalViewType.WEEK -> WeekView(state, vm, onEventClick) + CalViewType.DAY -> DayView(state, vm, onEventClick) + CalViewType.QUARTER -> QuarterView(state, onDayClick) + CalViewType.AGENDA -> AgendaView(state, vm, onEventClick) + } +} + +@Composable +private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> Unit) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(12.dp)) { + Text(message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall) + androidx.compose.foundation.layout.Row { + TextButton(onClick = onRetry) { Text(tr("common.retry")) } + TextButton(onClick = onDismiss) { Text(tr("common.close")) } + } + } + } +} + +/** + * Additive to [ErrorBanner]: the fetch as a whole succeeded, but one or more + * enabled calendars failed to sync (e.g. expired credentials) and are showing + * zero events with no other indication. Same visual language, no retry button + * (retrying the whole range wouldn't target just the broken calendar). + */ +@Composable +private fun SyncErrorBanner(errors: List, onDismiss: () -> Unit) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(12.dp)) { + errors.forEach { err -> + Text( + "${err.name}: ${err.message}", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + androidx.compose.foundation.layout.Row { + TextButton(onClick = onDismiss) { Text(tr("common.close")) } + } + } + } +} + +@Composable +private fun loadingPlaceholder() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun CompactTopBar( + title: String, + viewType: CalViewType, + loading: Boolean, + viewMenuOpen: Boolean, + showMenuButton: Boolean, + onMenu: () -> Unit, + onPrev: () -> Unit, + onToday: () -> Unit, + onNext: () -> Unit, + onViewMenuToggle: (Boolean) -> Unit, + onSelectView: (CalViewType) -> Unit, +) { + val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY + // Surface fills behind the status bar; the content column is inset below it. + Surface(color = MaterialTheme.colorScheme.background) { + Column(Modifier.statusBarsPadding()) { + Row( + Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Menu (hamburger) on the left — the drawer opens from the left. + if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu")) + CompactIcon(Icons.Filled.ChevronLeft, onPrev) + TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) { + Text(tr("nav.today"), fontSize = 13.sp) + } + CompactIcon(Icons.Filled.ChevronRight, onNext) + Text( + title, + modifier = Modifier.weight(1f).padding(horizontal = 4.dp), + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = if (twoLine) 13.sp else 16.sp, + lineHeight = if (twoLine) 15.sp else 18.sp, + fontWeight = FontWeight.Medium, + ) + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp).padding(end = 2.dp), + strokeWidth = 2.dp, + ) + } + Box { + CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) + DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) { + CalViewType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(tr("view.${type.key}")) }, + leadingIcon = { Icon(type.icon, contentDescription = null) }, + onClick = { onViewMenuToggle(false); onSelectView(type) }, + ) + } + } + } + } + Divider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + ) + } + } +} + +@Composable +private fun CompactIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, + contentDescription: String? = null, +) { + IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { + Icon(icon, contentDescription = contentDescription, modifier = Modifier.size(22.dp)) + } +} + +@Composable +private fun GroupBanner(group: Group, onExit: () -> Unit) { + Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp)) + Text( + "${tr("groups.view")}: ${group.name}", + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + TextButton(onClick = onExit) { Text(tr("group.switch.personal")) } + } + } +} + +/** Distinct calendars in the cache for the filter sheet — from the UNFILTERED + * cache (minus banished) so a locally quick-hidden calendar still shows up and + * can be toggled back on. */ +private fun allKnownCalendars(vm: CalendarViewModel): List { + val fromEvents = vm.knownCalendars() + .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) } + // Event-derived entries first (current server colour / owner name), then the + // full source list so calendars WITHOUT events in range still appear; + // distinctBy keeps the event-derived entry when a calendar has both. + return (fromEvents + vm.state.value.allCalendars) + .distinctBy { it.key } + .sortedBy { it.name.lowercase() } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..f479968 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -0,0 +1,803 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.data.SyncError +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.domain.model.GroupMember +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.util.Dates +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +/** Build the "source:id" visibility key, stripping any "-" prefix. */ +fun calendarKey(source: String, calendarId: String): String { + val prefix = "$source-" + val id = if (calendarId.startsWith(prefix)) calendarId.removePrefix(prefix) else calendarId + return "$source:$id" +} + +data class CalendarUiState( + val viewType: CalViewType = CalViewType.MONTH, + val currentDate: LocalDate = LocalDate.now(), + val events: List = emptyList(), + val isLoading: Boolean = false, + val isBackgroundCaching: Boolean = false, + val error: String? = null, + // Per-calendar sync failures from the last successful /events fetch (e.g. + // expired CalDAV credentials) — the fetch as a whole succeeded, but one or + // more enabled calendars silently returned nothing. Additive to `error`. + val syncErrors: List = emptyList(), + val weekStartsOnMonday: Boolean = true, + val writableCalendars: List = emptyList(), + val hiddenKeys: Set = emptySet(), + val banishedKeys: Set = emptySet(), + // Calendars ("source:id") the user muted for reminders — events keep their + // reminders but the scheduler skips them. + val reminderDisabledKeys: Set = emptySet(), + // Group overlay: when non-null the calendar shows the group's combined view. + val groups: List = emptyList(), + val activeGroup: Group? = null, + // Group overlay: full member list (for the filter) + per-member / group-cal + // hidden keys ("gm:" / "gc"). In-memory; reset when switching group. + val activeGroupMembers: List = emptyList(), + val hiddenGroupKeys: Set = emptySet(), + // Full calendar list across all sources (loaded on demand) so the filter + // shows every calendar, including ones with no events in the loaded range. + val allCalendars: List = emptyList(), + // Device-local: month view as horizontal paged (swipe) vs. scroll feed. + val monthViewPaged: Boolean = false, + // Device-local: hide the top-bar menu button (drawer opens via edge-swipe). + val hideMenuButton: Boolean = false, +) + +fun groupMemberKey(ownerId: Int): String = "gm:$ownerId" +const val GROUP_CALENDAR_KEY = "gc" + +@HiltViewModel +class CalendarViewModel @Inject constructor( + private val repository: CalendarRepository, + private val settingsStore: SettingsStore, +) : ViewModel() { + + private val zone: ZoneId = ZoneId.systemDefault() + + /** Current user id (for creator/owner comparisons in the UI). */ + val currentUserId: Int get() = repository.currentUserId + + /** Serializes network loads so overlapping fetches don't thrash the UI. */ + private val loadMutex = Mutex() + + private val _state = MutableStateFlow(initialState()) + val state: StateFlow = _state.asStateFlow() + + /** True once the first event load has completed — used to gate the splash. */ + private val _ready = MutableStateFlow(false) + val ready: StateFlow = _ready.asStateFlow() + + // Cache bookkeeping + private var cachedStart: Instant? = null + private var cachedEnd: Instant? = null + private var allCachedEvents: List = emptyList() + + init { + loadWritableCalendars() + loadGroups() + initialLoad(reconcile = true) + } + + /** + * Load the full ±cacheMonths window once, behind the splash, and only then + * mark ready. Entering the app fully-loaded avoids the first-open jank of + * loading a big batch while the user is already scrolling. + */ + private fun initialLoad(reconcile: Boolean = false) { + val months = settingsStore.cacheMonths.toLong() + val today = LocalDate.now().withDayOfMonth(1) + val start = instant(today.minusMonths(months)) + val end = instant(today.plusMonths(months + 1)) + viewModelScope.launch { + // Honour a calendar hidden/shown on the web BEFORE the first load so + // the visibility filter is correct before events are rendered. + if (reconcile) reconcileCalendarVisibility() + loadRange(start, end, background = false) + markReady() + } + } + + private fun initialState(): CalendarUiState { + val s = settingsStore.loadSettings() + return CalendarUiState( + viewType = CalViewType.fromKey(s.defaultView), + weekStartsOnMonday = s.weekStartsOnMonday, + hiddenKeys = settingsStore.hiddenCalendarKeys, + banishedKeys = settingsStore.banishedCalendarKeys, + reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys, + monthViewPaged = settingsStore.monthViewPaged, + hideMenuButton = settingsStore.hideMenuButton, + ) + } + + /** Re-read the device-local view prefs (called when settings close). */ + fun refreshMonthViewMode() { + _state.update { it.copy( + monthViewPaged = settingsStore.monthViewPaged, + hideMenuButton = settingsStore.hideMenuButton, + ) } + } + + // Device-local calendar order ("source:id"), mirrors the web cal_order. + val calendarOrder: List get() = settingsStore.calendarOrder + fun setCalendarOrder(keys: List) { 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, 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() + 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 { + val anchor = year ?: 2000 // leap-safe anchor for Feb 29 with unknown year + val date = LocalDate.of(anchor, month, day) + val start = date.atTime(12, 0).atZone(zone).toInstant() + val end = date.plusDays(1).atTime(12, 0).atZone(zone).toInstant() + return start to end + } + + /** Default duration (minutes) for a new event's end time. */ + val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes + + /** Default reminder offset (minutes before start), or -1 when off. */ + val defaultReminderMinutes: Int get() = settingsStore.loadSettings().defaultReminderMinutes ?: -1 + + /** Toggle a calendar's reminders without deleting any event reminders. */ + fun setCalendarRemindersDisabled(key: String, disabled: Boolean) { + val keys = settingsStore.reminderDisabledCalendarKeys.toMutableSet() + if (disabled) keys.add(key) else keys.remove(key) + settingsStore.reminderDisabledCalendarKeys = keys + _state.update { it.copy(reminderDisabledKeys = keys) } + val parts = key.split(":") + val source = parts.getOrNull(0) ?: return + val id = parts.getOrNull(1)?.toIntOrNull() ?: return + viewModelScope.launch { runCatching { repository.setCalendarRemindersEnabled(source, id, enabled = !disabled) } } + } + + // ---- Navigation ---- + + fun setViewType(type: CalViewType) { + _state.update { it.copy(viewType = type) } + loadVisible() + } + + fun moveToToday() { + _state.update { it.copy(currentDate = LocalDate.now()) } + loadVisible() + } + + fun navigatePrev() = navigate(-1) + fun navigateNext() = navigate(+1) + + private fun navigate(direction: Int) { + val st = _state.value + val date = st.currentDate + val newDate = when (st.viewType) { + CalViewType.WEEK -> date.plusWeeks(direction.toLong()) + CalViewType.DAY -> date.plusDays(direction.toLong()) + CalViewType.QUARTER -> date.plusMonths((3 * direction).toLong()) + else -> date.plusMonths(direction.toLong()) + } + _state.update { it.copy(currentDate = newDate) } + loadVisible() + } + + fun goToDate(date: LocalDate, viewType: CalViewType? = null) { + _state.update { it.copy(currentDate = date, viewType = viewType ?: it.viewType) } + loadVisible() + } + + // ---- Range ---- + + fun rangeForCurrentView(): Pair { + val st = _state.value + val date = st.currentDate + return when (st.viewType) { + CalViewType.MONTH -> { + val start = date.withDayOfMonth(1) + instant(start.minusMonths(1)) to instant(start.plusMonths(2)) + } + CalViewType.QUARTER -> { + val start = date.withDayOfMonth(1) + instant(start) to instant(start.plusMonths(4)) + } + CalViewType.WEEK -> { + val weekStart = startOfWeek(date, st.weekStartsOnMonday) + instant(weekStart) to instant(weekStart.plusDays(8)) + } + CalViewType.DAY -> instant(date) to instant(date.plusDays(1)) + CalViewType.AGENDA -> { + val today = LocalDate.now() + instant(today) to instant(today.plusDays(90)) + } + } + } + + private fun instant(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant() + + private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { + val dow = date.dayOfWeek.value // Mon=1 .. Sun=7 + val offset = if (mondayFirst) dow - 1 else dow % 7 + return date.minusDays(offset.toLong()) + } + + // ---- Loading ---- + + fun loadVisible(force: Boolean = false) { + val (start, end) = rangeForCurrentView() + if (!force && isCached(start, end)) { + refreshFromCache() + markReady() + return + } + viewModelScope.launch { + loadRange(start, end, background = false, force = force) + markReady() + } + } + + /** On-demand load for a month the user scrolled to in the continuous calendar. */ + fun ensureMonthLoaded(monthAnchor: LocalDate) { + val first = monthAnchor.withDayOfMonth(1) + val start = instant(first.minusMonths(1)) + val end = instant(first.plusMonths(2)) + if (isCached(start, end)) return + viewModelScope.launch { loadRange(start, end, background = false) } + } + + /** Single serialized loader: avoids overlapping fetches that cause scroll jank. */ + private suspend fun loadRange(start: Instant, end: Instant, background: Boolean, force: Boolean = false) { + loadMutex.withLock { + // Another load (e.g. the background prefetch) may have covered this range. + // `force` bypasses this so callers can explicitly re-fetch an already-cached + // range (e.g. to pick up a mutation) without wiping the whole cache first. + if (!force && isCached(start, end)) return + val group = _state.value.activeGroup + val flag = if (background) "bg" else "fg" + _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } + runCatching { + if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList() + else repository.fetchEvents(start, end).let { it.events to it.errors } + } + .onSuccess { (events, errors) -> + val (keepKeys, keepSources) = failedCalendarKeys(errors) + mergeIntoCache(events, start, end, keepKeys, keepSources) + refreshFromCache() + _state.update { it.copy(syncErrors = errors) } + } + .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } + _state.update { it.copy(isLoading = false, isBackgroundCaching = false) } + } + } + + /** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */ + private fun decorateGroup(events: List): List { + val me = currentUserId + return events.map { ev -> + // Prefer the server-decorated title (group icon + owner prefix) so + // web, iOS and Android render identically; fall back for old servers. + val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() } + if (serverTitle != null) return@map ev.copy(title = serverTitle) + val prefix = when { + ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: " + ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: " + else -> "" + } + if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title) + } + } + + private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s } + + private fun markReady() { + _ready.value = true + } + + private fun isCached(start: Instant, end: Instant): Boolean { + val cs = cachedStart ?: return false + val ce = cachedEnd ?: return false + return !cs.isAfter(start) && !ce.isBefore(end) + } + + /** + * Split sync errors into per-calendar keys and whole sources whose cached + * events must NOT be evicted on a partial sync — stale data beats an empty + * calendar. An error carrying a calendar_id protects just that calendar; an + * account-level error without one (e.g. an HA / Google token-refresh + * failure, which aborts the whole account fetch) protects every cached + * calendar of that source. + */ + private fun failedCalendarKeys(errors: List): Pair, Set> { + val keys = mutableSetOf() + val sources = mutableSetOf() + for (err in errors) { + val cid = err.calendarId + if (cid != null) keys.add(calendarKey(err.source, cid.toString())) + else sources.add(err.source) + } + return keys to sources + } + + private fun mergeIntoCache( + newEvents: List, rangeStart: Instant, rangeEnd: Instant, + keepKeysInRange: Set = emptySet(), + keepSourcesInRange: Set = emptySet(), + ) { + // Remove old events in the fetched range to avoid duplicates — but + // PRESERVE events from calendars / sources that had sync errors so a + // transient CalDAV / Google / HA failure doesn't wipe the visible calendar. + val retained = allCachedEvents.filter { ev -> + val outsideRange = !ev.startDate.isBefore(rangeEnd) || !ev.endDate.isAfter(rangeStart) + when { + outsideRange -> true + ev.source in keepSourcesInRange -> true + keepKeysInRange.isEmpty() -> false + else -> calendarKey(ev.source, ev.calendarId) in keepKeysInRange + } + } + allCachedEvents = retained + newEvents + // Only extend the cached range on a fully clean fetch. When some + // calendars/sources failed, leave cachedStart/End unchanged so + // isCached() stays false and they're retried on the next load rather + // than being silently treated as "done" with empty data. + if (keepKeysInRange.isEmpty() && keepSourcesInRange.isEmpty()) { + cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart + cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd + } + } + + private fun refreshFromCache() { + val st = _state.value + // In group mode: server scopes/filters by privacy; locally honour the + // per-member / group-calendar hide toggles (hiddenGroupKeys). + val visible = if (st.activeGroup != null) { + val hg = st.hiddenGroupKeys + if (hg.isEmpty()) allCachedEvents + else allCachedEvents.filter { ev -> + when { + ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg + ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg + else -> true + } + } + } else { + val hidden = st.hiddenKeys + val banished = st.banishedKeys + if (hidden.isEmpty() && banished.isEmpty()) allCachedEvents + else allCachedEvents.filter { ev -> + val key = calendarKey(ev.source, ev.calendarId) + key !in hidden && key !in banished + } + } + // Skip the state write (and resulting recomposition) when nothing changed. + _state.update { if (it.events == visible) it else it.copy(events = visible) } + } + + private fun invalidateCache() { + cachedStart = null + cachedEnd = null + allCachedEvents = emptyList() + } + + fun syncWithServer() { + invalidateCache() + loadGroups() + initialLoad(reconcile = true) + } + + /** + * Called when the app returns to the foreground. Re-checks the server's + * per-calendar visibility (a calendar may have been hidden/shown on the web + * or another device meanwhile) and reloads only if something changed. + */ + fun onAppResumed() { + viewModelScope.launch { + if (reconcileCalendarVisibility()) { + invalidateCache() + loadGroups() + initialLoad(reconcile = false) // just reconciled above + } + } + } + + /** + * Reconcile the local **banished** set with the server's per-calendar + * `sidebar_hidden` flags for external calendars (CalDAV / Google / HA). + * Returns `true` if the set changed, so the caller can force a refetch — a + * calendar re-enabled on the web has NO events in the cache (the server + * excludes a hidden calendar's events entirely). + * + * NOTE: This deliberately drives `banishedKeys`, NOT `hiddenKeys`. The + * quick-hide (`hiddenKeys`) is a device-local filter and must never be + * overwritten from the server; only the "banish / permanently hide" state + * maps to the server's `sidebar_hidden` (mirrors iOS). + */ + private suspend fun reconcileCalendarVisibility(): Boolean { + val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()) + val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()) + val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()) + + val banished = settingsStore.banishedCalendarKeys.toMutableSet() + fun apply(source: String, id: Int, serverHidden: Boolean) { + val key = calendarKey(source, id.toString()) + if (serverHidden) banished.add(key) else banished.remove(key) + } + caldav.forEach { acc -> acc.calendars?.forEach { apply("caldav", it.id, it.sidebarHidden) } } + google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } } + ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } } + + if (banished == settingsStore.banishedCalendarKeys) return false + settingsStore.banishedCalendarKeys = banished + _state.update { it.copy(banishedKeys = banished) } + return true + } + + fun clearError() = _state.update { it.copy(error = null) } + + fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) } + + // ---- Visibility filters ---- + + fun setCalendarHidden(key: String, hidden: Boolean) { + val next = _state.value.hiddenKeys.toMutableSet().apply { + if (hidden) add(key) else remove(key) + } + settingsStore.hiddenCalendarKeys = next + _state.update { it.copy(hiddenKeys = next) } + refreshFromCache() + } + + fun setHiddenCalendars(keys: Set) { + settingsStore.hiddenCalendarKeys = keys + _state.update { it.copy(hiddenKeys = keys) } + refreshFromCache() + } + + /** Distinct calendars present in the full cache, IGNORING the device-local + * quick-hide filter but excluding banished ones — for the filter sheet, so + * a locally hidden calendar still appears there and can be toggled back on. */ + fun knownCalendars(): List { + val banished = _state.value.banishedKeys + return allCachedEvents + .distinctBy { calendarKey(it.source, it.calendarId) } + .filter { calendarKey(it.source, it.calendarId) !in banished } + } + + /** + * Load the full calendar list across all sources into [CalendarUiState.allCalendars] + * so the filter shows every calendar, even ones with no events in the loaded + * range. Called when the filter sheet opens. Read-only flag for shared local + * calendars from owned/permission; banished calendars are dropped. + */ + fun loadAllCalendars() { + viewModelScope.launch { + val banished = _state.value.banishedKeys + val entries = mutableListOf() + runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()).forEach { c -> + entries += CalendarFilterEntry( + key = calendarKey("local", c.id.toString()), + name = if (c.owned) c.name else (c.sharedBy ?: c.name), + color = c.color, + source = "local", + readOnly = !c.owned && c.permission != "read_write", + ) + } + runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().forEach { c -> + entries += CalendarFilterEntry(calendarKey("caldav", c.id.toString()), c.name, c.color ?: acc.color, "caldav") + } + } + runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().forEach { c -> + entries += CalendarFilterEntry(calendarKey("google", c.id.toString()), c.name, c.color ?: "#4285f4", "google") + } + } + runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().forEach { c -> + entries += CalendarFilterEntry(calendarKey("homeassistant", c.id.toString()), c.name, c.color ?: "#46bdc6", "homeassistant") + } + } + runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()).forEach { s -> + entries += CalendarFilterEntry(calendarKey("ical", s.id.toString()), s.name, s.color, "ical") + } + _state.update { st -> st.copy(allCalendars = entries.filter { it.key !in banished }) } + } + } + + /** The user's single birthday calendar, or null if not activated yet. */ + suspend fun birthdayCalendar(): LocalCalendar? = + runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()) + .firstOrNull { it.isBirthday && it.owned } + + /** The birthday calendar, creating the single one (named [name]) if none. */ + suspend fun ensureBirthdayCalendar(name: String): LocalCalendar? { + birthdayCalendar()?.let { return it } + return runCatching { + repository.addLocalCalendar(name, "#E0407F", isBirthday = true) + }.getOrNull() + } + + /** + * Create a manual birthday: an all-day, yearly-recurring local event whose + * age suffix and cake icon are added server-side. When the year is unknown + * we anchor to 1970 and send no birth_year (so no age is shown). + */ + fun createBirthday( + calendarId: Int, name: String, date: LocalDate, yearKnown: Boolean, onDone: () -> Unit, + ) { + viewModelScope.launch { + val anchor = LocalDate.of(if (yearKnown) date.year else 1970, date.monthValue, date.dayOfMonth) + val start = Dates.startOfDay(anchor) + val end = Dates.startOfDay(anchor.plusDays(1)) + runCatching { + repository.createLocalEvent( + calendarId, name, start, end, isAllDay = true, + location = "", description = "", color = null, + rrule = "FREQ=YEARLY", birthYear = if (yearKnown) date.year else null, + ) + }.onSuccess { loadVisible(force = true); onDone() } + } + } + + /** + * Banish ("permanently hide") a calendar, or lift the banish. Unlike the + * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for + * external calendars, matching iOS. Banishing also clears any local + * quick-hide flag for the same key (redundant once banished). + */ + fun setCalendarBanished(key: String, banished: Boolean) { + val nextBanished = _state.value.banishedKeys.toMutableSet().apply { + if (banished) add(key) else remove(key) + } + val nextHidden = _state.value.hiddenKeys.toMutableSet().apply { + if (banished) remove(key) + } + settingsStore.banishedCalendarKeys = nextBanished + settingsStore.hiddenCalendarKeys = nextHidden + _state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) } + refreshFromCache() + + val parts = key.split(":") + val source = parts.getOrNull(0) + val id = parts.getOrNull(1)?.toIntOrNull() + if (source != null && id != null && source in listOf("caldav", "google", "homeassistant")) { + viewModelScope.launch { + runCatching { repository.setCalendarSidebarHidden(source, id, banished) } + .onFailure { e -> _state.update { it.copy(error = e.message ?: "Fehler beim Speichern") } } + // Un-banishing re-enables the calendar on the server, but its + // events were excluded while hidden — force a refetch so they + // reappear without a manual sync. + if (!banished) { + invalidateCache() + initialLoad(reconcile = false) + } + } + } + } + + // ---- Groups ---- + + fun loadGroups() { + viewModelScope.launch { + runCatching { repository.getGroups() } + .onSuccess { gs -> + _state.update { st -> + // If the active group was deleted elsewhere, drop back to personal. + val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } } + st.copy(groups = gs, activeGroup = stillActive) + } + } + } + } + + /** Flip between personal and a group's combined overlay; reloads the wide window. */ + fun switchGroup(group: Group?) { + if (_state.value.activeGroup?.id == group?.id) return + _state.update { + it.copy(activeGroup = group, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList()) + } + invalidateCache() + initialLoad() + // Load the full member list (with server colours) for the filter sheet. + if (group != null) { + viewModelScope.launch { + runCatching { repository.getGroup(group.id) } + .onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } } + } + } + } + + /** Toggle a single member's calendar / the group calendar in the overlay. */ + fun setGroupKeyHidden(key: String, hidden: Boolean) { + _state.update { + val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) } + it.copy(hiddenGroupKeys = next) + } + refreshFromCache() + } + + /** Replace the group-overlay hidden set (bulk show/hide all). */ + fun setHiddenGroupKeys(keys: Set) { + _state.update { it.copy(hiddenGroupKeys = keys) } + refreshFromCache() + } + + // ---- Writable calendars ---- + + fun loadWritableCalendars() { + viewModelScope.launch { + runCatching { repository.getWritableCalendars() } + .onSuccess { cals -> _state.update { it.copy(writableCalendars = cals) } } + } + } + + // ---- Event mutations ---- + + /** + * Re-fetch just the already-cached range after a create/update so the edit is + * reflected everywhere it's currently loaded, without nuking and reloading the + * whole ±cacheMonths window (which caused a full-screen freeze on every save). + * Falls back to [initialLoad] if nothing was cached yet. + */ + private fun afterMutation() { + val start = cachedStart + val end = cachedEnd + if (start == null || end == null) { + initialLoad() + return + } + viewModelScope.launch { + loadRange(start, end, background = false, force = true) + markReady() + } + } + + fun saveEvent( + calendar: WritableCalendar, + existing: CalEvent?, + title: String, + start: Instant, + end: Instant, + isAllDay: Boolean, + location: String, + description: String, + color: String?, + isPrivate: Boolean, + reminders: List = emptyList(), + onResult: (String?) -> Unit, + ) { + viewModelScope.launch { + val result = runCatching { + if (existing != null && existing.source == calendar.source) { + when (existing.source) { + "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders) + "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) + "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) + "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) + else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders) + } + } else { + createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders) + } + } + result.onSuccess { afterMutation(); onResult(null) } + .onFailure { onResult(it.message ?: "Fehler") } + } + } + + private suspend fun createForSource( + calendar: WritableCalendar, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean, + reminders: List = emptyList(), + ) { + when (calendar.source) { + "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders) + "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) + "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) + "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) + } + } + + fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) { + viewModelScope.launch { + val result = runCatching { + when (event.source) { + "local" -> repository.deleteLocalEvent(event.id) + "caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event)) + "homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id) + "google" -> repository.deleteGoogleEvent(calendarNumericId(event) ?: 0, event.id) + else -> Unit + } + } + result.onSuccess { + removeCachedEvent(event.id) + onResult(null) + }.onFailure { onResult(it.message ?: "Fehler") } + } + } + + private fun calendarNumericId(event: CalEvent): Int? { + val key = calendarKey(event.source, event.calendarId) + return key.substringAfter(":").toIntOrNull() + } + + private fun removeCachedEvent(id: String) { + allCachedEvents = allCachedEvents.filterNot { it.id == id } + refreshFromCache() + } + + /** Events overlapping a single day, sorted by start. */ + fun eventsOn(date: LocalDate, events: List): List { + val dayStart = instant(date) + val dayEnd = instant(date.plusDays(1)) + return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) } + .sortedBy { it.startDate } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt new file mode 100644 index 0000000..2a7bced --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt @@ -0,0 +1,255 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.TextStyle + +/** + * Long-press day preview (mirrors the iOS DayContextPreviewView): all-day + * events as colored bars with tapered ends when they continue beyond this day, + * timed events as dot + time + title rows, followed by quick actions. + */ +@Composable +fun DayPreviewDialog( + date: LocalDate, + events: List, + onDismiss: () -> Unit, + onEventClick: (CalEvent) -> Unit, + onCreateEvent: () -> Unit, + onOpenDay: () -> Unit, + onOpenWeek: () -> Unit, +) { + val lang = LocalLang.current + val sorted = remember(events) { + events.sortedWith(compareByDescending { it.isAllDay }.thenBy { it.startDate }) + } + val weekdayAbbr = remember(date, lang) { + date.dayOfWeek.getDisplayName(TextStyle.SHORT, L10n.locale(lang)).uppercase() + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Column( + Modifier + .width(290.dp) + .padding(vertical = 14.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + weekdayAbbr, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 6.dp, bottom = 3.dp), + ) + Text( + "${date.dayOfMonth}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + + if (sorted.isEmpty()) { + Text( + tr("cal.no_events_day"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } else { + sorted.forEach { ev -> + if (ev.isAllDay) { + AllDayPreviewBar(event = ev, date = date, onClick = { onEventClick(ev) }) + } else { + TimedPreviewRow(event = ev, lang = lang, onClick = { onEventClick(ev) }) + } + } + } + + Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + PreviewAction(Icons.Filled.Add, tr("cal.new_event"), onCreateEvent) + PreviewAction(Icons.Filled.CalendarViewWeek, tr("cal.show_in_week_view"), onOpenWeek) + PreviewAction(Icons.Filled.WbSunny, tr("cal.show_in_day_view"), onOpenDay) + } + } + } +} + +@Composable +private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + val startDay = localDate(event.startDate) + val lastDay = localDate(event.endDate.minusSeconds(1)) // all-day end is exclusive + val cLeft = startDay.isBefore(date) + val cRight = lastDay.isAfter(date) + val shape = remember(cLeft, cRight) { ChevronBarShape(cLeft, cRight) } + + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(shape) + .background(color) + .clickable(onClick = onClick) + .padding( + start = if (cLeft) 14.dp else 8.dp, + end = if (cRight) 14.dp else 8.dp, + top = 4.dp, + bottom = 4.dp, + ), + ) { + EventLabel( + event = event, + color = color.contrastingTextColor(), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(colorFromHex(event.effectiveColor)), + ) + Text( + timeLabel(event.startDate, lang), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp).width(42.dp), + ) + EventLabel( + event = event, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun PreviewAction(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +/** + * Bar shape with tapered (pointed) ends on the sides where the event continues + * beyond the shown day — port of the iOS ChevronBarShape. + */ +private class ChevronBarShape( + private val continuesLeft: Boolean, + private val continuesRight: Boolean, +) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val r = with(density) { 4.dp.toPx() } + if (!continuesLeft && !continuesRight) { + return Outline.Rounded(RoundRect(size.toRect(), CornerRadius(r))) + } + val t = with(density) { 8.dp.toPx() }.coerceAtMost(size.width / 2) + val p = Path() + if (continuesLeft && continuesRight) { + p.moveTo(t, 0f) + p.lineTo(size.width - t, 0f) + p.lineTo(size.width, size.height / 2) + p.lineTo(size.width - t, size.height) + p.lineTo(t, size.height) + p.lineTo(0f, size.height / 2) + } else if (continuesLeft) { + p.moveTo(t, 0f) + p.lineTo(size.width, 0f) + p.lineTo(size.width, size.height) + p.lineTo(t, size.height) + p.lineTo(0f, size.height / 2) + } else { + p.moveTo(0f, 0f) + p.lineTo(size.width - t, 0f) + p.lineTo(size.width, size.height / 2) + p.lineTo(size.width - t, size.height) + p.lineTo(0f, size.height) + } + p.close() + return Outline.Generic(p) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/EventLabel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/EventLabel.kt new file mode 100644 index 0000000..e2e7be7 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/EventLabel.kt @@ -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), + ) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt new file mode 100644 index 0000000..7484ca5 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -0,0 +1,512 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.time.temporal.IsoFields +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +private const val MONTHS_BACK = 120L // 10 years back +private const val MONTHS_AHEAD = 600L // 50 years ahead +private const val MAX_LANES = 4 +private val DAY_NUM_H = 22.dp +private val LANE_H = 15.dp +private val LANE_SPACE = 2.dp +// Bottom strip must fit the 8sp "+N"/"KW" labels without event bars overlapping them. +private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 16.dp + +private enum class DividerEdge { NONE, TOP, BOTTOM } + +/** A placed event bar within a week (with colours resolved once, not per frame). */ +private data class PlacedBar( + val event: CalEvent, + val lane: Int, + val startCol: Int, + val span: Int, + val color: Color, + val textColor: Color, +) + +private class WeekLayout(val bars: List, val overflowPerCol: IntArray) + +/** Month calendar with multi-day event bars (iOS-style). Two modes: a + * continuous vertical scroll feed, or a horizontally-paged one-month-per-screen + * grid (swipe left/right), toggled by [CalendarUiState.monthViewPaged]. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MonthView( + state: CalendarUiState, + vm: CalendarViewModel, + listState: LazyListState, + scrollToTodaySignal: Int, + monthJumpSignal: Int, + monthJumpTarget: LocalDate?, + onVisibleMonthChange: (LocalDate) -> Unit, + onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, +) { + val lang = LocalLang.current + val settings = LocalAppSettings.current + val mondayFirst = state.weekStartsOnMonday + val today = LocalDate.now() + + val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant + val outline = MaterialTheme.colorScheme.outline + val onBackground = MaterialTheme.colorScheme.onBackground + val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) } + val labelColor = remember(settings.monthLabelColor, fallbackLabel) { colorFromHex(settings.monthLabelColor, fallbackLabel) } + val gridColor = remember(outline, settings.lineContrast) { outline.copy(alpha = gridLineOpacity(settings.lineContrast)) } + val secondaryText = remember(onBackground, settings.textContrast) { onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) } + val todayColor = remember(settings.todayColor) { colorFromHex(settings.todayColor) } + + // Column width measured once → no per-row BoxWithConstraints (smooth scrolling). + val density = LocalDensity.current + val fallbackCellW = LocalConfiguration.current.screenWidthDp.dp / 7 + var gridWidthPx by remember { mutableIntStateOf(0) } + val cellW: Dp = if (gridWidthPx > 0) with(density) { (gridWidthPx / 7f).toDp() } else fallbackCellW + + val firstVisible = remember(mondayFirst) { + startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) + } + val end = remember(mondayFirst) { + startOfWeek(today.withDayOfMonth(1).plusMonths(MONTHS_AHEAD), mondayFirst) + } + val weekCount = remember(firstVisible, end) { + (ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1) + } + fun weekIndexOf(date: LocalDate): Int = + ChronoUnit.WEEKS.between(firstVisible, startOfWeek(date, mondayFirst)).toInt().coerceIn(0, weekCount - 1) + + val todayIndex = remember(firstVisible) { weekIndexOf(today) } + + // Month-paged mode bookkeeping (one page per calendar month). + val firstMonth = remember { today.withDayOfMonth(1).minusMonths(MONTHS_BACK) } + val monthCount = remember { (MONTHS_BACK + MONTHS_AHEAD + 1).toInt() } + fun monthIndexOf(date: LocalDate): Int = + ChronoUnit.MONTHS.between(firstMonth, date.withDayOfMonth(1)).toInt().coerceIn(0, monthCount - 1) + val todayMonthIndex = remember(firstMonth) { monthIndexOf(today) } + val pagerState = rememberPagerState(initialPage = todayMonthIndex) { monthCount } + + val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) } + val dimPast = settings.dimPastEvents + val cwLabel = tr("cal.cw") + // remember: a fresh Instant per recomposition would invalidate every visible + // WeekRow (the main source of scroll jank); minute precision is plenty here. + val now = remember { java.time.Instant.now() } + + // Route the title / prev-next / today signals to the active surface (pager or list). + if (state.monthViewPaged) { + LaunchedEffect(scrollToTodaySignal) { + if (scrollToTodaySignal > 0) pagerState.animateScrollToPage(todayMonthIndex) + } + LaunchedEffect(monthJumpSignal) { + if (monthJumpSignal > 0 && monthJumpTarget != null) pagerState.animateScrollToPage(monthIndexOf(monthJumpTarget)) + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .map { firstMonth.plusMonths(it.toLong()) } + .distinctUntilChanged() + .collect { month -> + onVisibleMonthChange(month) + vm.ensureMonthLoaded(month) + } + } + } else { + LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) } + LaunchedEffect(scrollToTodaySignal) { + if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) + } + LaunchedEffect(monthJumpSignal) { + if (monthJumpSignal > 0 && monthJumpTarget != null) { + listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1))) + } + } + LaunchedEffect(listState, weekCount) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } + .distinctUntilChanged() + .collect { month -> + onVisibleMonthChange(month) + vm.ensureMonthLoaded(month) + } + } + } + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { + weekdayLabels(mondayFirst, lang).forEach { label -> + Text( + label, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (state.monthViewPaged) { + // One month per page, six height-filling week rows, swipe to change month. + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width }, + ) { page -> + val month = firstMonth.plusMonths(page.toLong()) + val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst) + Column(Modifier.fillMaxSize()) { + repeat(6) { w -> + val weekStart = firstWeek.plusWeeks(w.toLong()) + WeekRow( + rowModifier = Modifier.fillMaxWidth().weight(1f), + weekStart = weekStart, + today = today, + weekEvents = eventsByWeek[weekStart] ?: emptyList(), + cellW = cellW, + dimPast = dimPast, + now = now, + lang = lang, + cwLabel = cwLabel, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = todayColor, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) + } + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width }, + ) { + items(weekCount, key = { it }, contentType = { "week" }) { index -> + val weekStart = firstVisible.plusWeeks(index.toLong()) + WeekRow( + rowModifier = Modifier.fillMaxWidth().height(ROW_HEIGHT), + weekStart = weekStart, + today = today, + weekEvents = eventsByWeek[weekStart] ?: emptyList(), + cellW = cellW, + dimPast = dimPast, + now = now, + lang = lang, + cwLabel = cwLabel, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = todayColor, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) + } + } + } + } +} + +@Composable +private fun WeekRow( + rowModifier: Modifier, + weekStart: LocalDate, + today: LocalDate, + weekEvents: List, + cellW: Dp, + dimPast: Boolean, + now: java.time.Instant, + lang: String, + cwLabel: String, + dividerColor: Color, + gridColor: Color, + labelColor: Color, + secondaryText: Color, + todayColor: Color, + onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, +) { + val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } } + val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 } + val rowStartsNewMonth = days[0].dayOfMonth == 1 + + val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) } + + // Scroll mode passes a fixed ROW_HEIGHT; paged mode passes weight(1f) so six + // rows fill the screen. Event bars anchor to the top; the day cells fill height. + Box(rowModifier) { + Row(Modifier.fillMaxSize()) { + days.forEachIndexed { idx, day -> + val edge = when { + boundaryCol != null -> if (idx < boundaryCol) DividerEdge.BOTTOM else DividerEdge.TOP + rowStartsNewMonth -> DividerEdge.TOP + else -> DividerEdge.NONE + } + DayCellBackground( + day = day, + isToday = day == today, + isMonday = day.dayOfWeek == DayOfWeek.MONDAY, + weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR), + cwLabel = cwLabel, + overflow = packed.overflowPerCol[idx], + lang = lang, + edge = edge, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = todayColor, + onClick = { onDayClick(day) }, + onLongClick = { onDayLongPress(day) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + + packed.bars.forEach { bar -> + val past = dimPast && bar.event.endDate.isBefore(now) + EventBar(bar = bar, cellW = cellW, dimmed = past, onClick = { onEventClick(bar.event) }) + } + + if (boundaryCol != null) { + Box( + Modifier + .offset(x = cellW * boundaryCol) + .width(1.5.dp) + .fillMaxHeight() + .background(dividerColor), + ) + } + } +} + +@Composable +private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () -> Unit) { + Box( + Modifier + .offset( + x = cellW * bar.startCol + 1.dp, + y = DAY_NUM_H + (LANE_H + LANE_SPACE) * bar.lane, + ) + .width(cellW * bar.span - 2.dp) + .height(LANE_H) + .alpha(if (dimmed) 0.45f else 1f) + .clip(RoundedCornerShape(3.dp)) + .background(bar.color) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + EventLabel( + event = bar.event, + color = bar.textColor, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DayCellBackground( + day: LocalDate, + isToday: Boolean, + isMonday: Boolean, + weekNumber: Int, + cwLabel: String, + overflow: Int, + lang: String, + edge: DividerEdge, + dividerColor: Color, + gridColor: Color, + labelColor: Color, + secondaryText: Color, + todayColor: Color, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isFirst = day.dayOfMonth == 1 + val onBg = MaterialTheme.colorScheme.onBackground + + Box( + modifier = modifier + .drawBehind { + val gridW = 0.5.dp.toPx() + val divW = 1.5.dp.toPx() + if (edge == DividerEdge.TOP) { + drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW) + } else { + drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW) + } + if (edge == DividerEdge.BOTTOM) { + drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW) + } + drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW) + } + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 1.dp), + ) { + Row( + Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + color = labelColor, + modifier = Modifier.padding(end = 2.dp), + ) + } + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else onBg, + ) + } + } + if (overflow > 0) { + Text( + "+$overflow", + fontSize = 8.sp, + color = secondaryText, + modifier = Modifier.align(Alignment.BottomStart).padding(start = 3.dp, bottom = 1.dp), + ) + } + if (isMonday) { + Text( + "$cwLabel $weekNumber", + fontSize = 8.sp, + color = secondaryText, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 1.dp), + ) + } + } +} + +private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { + val dow = date.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + return date.minusDays(offset.toLong()) +} + +/** Columns [startCol, startCol+span-1] this event occupies within the given week. */ +private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair { + val weekEnd = weekStart.plusDays(7) + val evStartDay = maxOf(localDate(ev.startDate), weekStart) + val lastDay = localDate(ev.endDate.minusSeconds(1)) + val evEndDay = minOf(lastDay, weekEnd.minusDays(1)) + val sc = ChronoUnit.DAYS.between(weekStart, evStartDay).toInt().coerceIn(0, 6) + val ec = ChronoUnit.DAYS.between(weekStart, evEndDay).toInt().coerceIn(0, 6) + return sc to (ec - sc + 1).coerceAtLeast(1) +} + +/** Greedy first-fit lane packing (mirrors iOS packEvents); colours resolved here. */ +private fun packEvents(weekStart: LocalDate, weekEvents: List): WeekLayout { + val sorted = weekEvents.sortedWith(compareBy { it.startDate }.thenByDescending { it.endDate }) + val laneLastEnd = ArrayList() + val bars = ArrayList() + val overflow = IntArray(7) + for (ev in sorted) { + val (sc, span) = columnRange(weekStart, ev) + val lastCol = (sc + span - 1).coerceAtMost(6) + var assigned = -1 + for (i in laneLastEnd.indices) { + if (laneLastEnd[i] < sc) { laneLastEnd[i] = lastCol; assigned = i; break } + } + if (assigned == -1 && laneLastEnd.size < MAX_LANES) { + laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1 + } + if (assigned >= 0) { + val color = colorFromHex(ev.effectiveColor) + bars.add(PlacedBar(ev, assigned, sc, span, color, color.contrastingTextColor())) + } else { + for (c in sc..lastCol) overflow[c]++ + } + } + return WeekLayout(bars, overflow) +} + +/** Bucket events into every week (week-start key) they overlap. */ +private fun buildEventsByWeek(events: List, mondayFirst: Boolean): Map> { + val map = HashMap>() + for (ev in events) { + val startD = localDate(ev.startDate) + val firstWeek = startOfWeek(startD, mondayFirst) + val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(startD)) startD else it } + val lastWeek = startOfWeek(lastDay, mondayFirst) + var w = firstWeek + var guard = 0 + while (!w.isAfter(lastWeek) && guard < 120) { + map.getOrPut(w) { mutableListOf() }.add(ev) + w = w.plusWeeks(1) + guard++ + } + } + return map +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt new file mode 100644 index 0000000..e6387b3 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt @@ -0,0 +1,104 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun QuarterView(state: CalendarUiState, onDayClick: (LocalDate) -> Unit) { + val lang = LocalLang.current + val months = (0 until 3).map { state.currentDate.withDayOfMonth(1).plusMonths(it.toLong()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) { + months.forEach { month -> + MiniMonth(month, state, lang, onDayClick) + } + } +} + +@Composable +private fun MiniMonth(month: LocalDate, state: CalendarUiState, lang: String, onDayClick: (LocalDate) -> Unit) { + val mondayFirst = state.weekStartsOnMonday + val today = LocalDate.now() + val titleFmt = DateTimeFormatter.ofPattern("LLLL yyyy", com.scarriffle.calendarr.ui.L10n.locale(lang)) + val dow = month.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + val firstVisible = month.minusDays(offset.toLong()) + val cells = (0 until 42).map { firstVisible.plusDays(it.toLong()) } + + Column(Modifier.padding(bottom = 16.dp)) { + Text( + titleFmt.format(month).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp), + ) + Row(Modifier.fillMaxWidth()) { + weekdayLabels(mondayFirst, lang).forEach { + Text(it, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 9.sp, color = MaterialTheme.colorScheme.outline) + } + } + cells.chunked(7).forEach { week -> + Row(Modifier.fillMaxWidth()) { + week.forEach { day -> + val hasEvents = state.events.any { + localDate(it.startDate) <= day && localDate(it.endDate) >= day + } + val firstColor = state.events.firstOrNull { + localDate(it.startDate) <= day && localDate(it.endDate) >= day + }?.effectiveColor + Box( + Modifier.weight(1f).aspectRatio(1f).clickable { onDayClick(day) }, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val isToday = day == today + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box( + Modifier.clip(CircleShape) + .background(colorFromHex(LocalAppSettings.current.todayColor)) + .padding(horizontal = 5.dp, vertical = 2.dp), + ) { Text("${day.dayOfMonth}", fontSize = 10.sp, color = colorFromHex(LocalAppSettings.current.todayColor).contrastingTextColor()) } + } else { + Text( + "${day.dayOfMonth}", + fontSize = 10.sp, + color = if (day.month == month.month) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.outline, + ) + } + } + if (hasEvents) { + Box(Modifier.padding(top = 1.dp).clip(CircleShape).background(colorFromHex(firstColor)).padding(2.dp)) + } + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt new file mode 100644 index 0000000..d5e8f7f --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt @@ -0,0 +1,267 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +private val GUTTER = 48.dp + +/** An all-day event laid out as a bar spanning columns [start]..[end] in lane [lane]. */ +private data class AllDayBar(val ev: CalEvent, val start: Int, val end: Int, val lane: Int) + +@Composable +fun TimeGridView( + days: List, + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, +) { + val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp + val dimPast = LocalAppSettings.current.dimPastEvents + val today = LocalDate.now() + // Recomputed once per day (not on every recomposition/frame) — Instant.now() + // was previously called fresh each recomposition, causing visible flicker in + // the "is this now" past-dimming and wasted allocation. + val now = remember(today) { java.time.Instant.now() } + val lang = LocalLang.current + + Column(Modifier.fillMaxSize()) { + // Day headers (only for multi-day / week view) + if (days.size > 1) { + Row(Modifier.fillMaxWidth()) { + Box(Modifier.width(GUTTER)) + val dayFmt = remember(lang) { + DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang)) + } + days.forEach { day -> + Text( + dayFmt.format(day), + modifier = Modifier.weight(1f).padding(vertical = 4.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (day == today) FontWeight.Bold else FontWeight.Normal, + color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // All-day row: continuous bars spanning an event's start→end columns and + // packed into lanes, instead of repeating a multi-day event once per day. + val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } } + if (perDay.any { it.isNotEmpty() }) { + // event id -> [minCol, maxCol] within this week + val range = LinkedHashMap() + val evById = HashMap() + 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 + } + } + // earlier / longer first, then greedily pack into lanes + val spans = range.entries + .map { Triple(evById.getValue(it.key), it.value[0], it.value[1]) } + .sortedWith(compareBy({ it.second }, { -(it.third - it.second) })) + val laneEnd = ArrayList() + val bars = ArrayList() + 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())) + } + } + } + Divider() + } + + // Scrollable hour grid + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Box(Modifier.fillMaxWidth().height(hourHeight * 24)) { + // Hour lines + labels + for (h in 0..24) { + val y = hourHeight * h.toFloat() + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(start = GUTTER) + .offset(y = y), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + if (h < 24) { + Text( + "%02d:00".format(h), + modifier = Modifier.width(GUTTER).offset(y = y + 2.dp).padding(end = 4.dp), + textAlign = TextAlign.End, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.outline, + ) + } + } + + // Timed events per day column + days.forEachIndexed { index, day -> + val timed = vm.eventsOn(day, state.events).filter { !it.isAllDay } + timed.forEach { ev -> + TimedEvent( + event = ev, + day = day, + index = index, + dayCount = days.size, + hourHeight = hourHeight, + dimmed = dimPast && ev.endDate.isBefore(now), + onClick = { onEventClick(ev) }, + ) + } + } + } + } + } +} + +@Composable +private fun TimedEvent( + event: CalEvent, + day: LocalDate, + index: Int, + dayCount: Int, + hourHeight: androidx.compose.ui.unit.Dp, + dimmed: Boolean, + onClick: () -> Unit, +) { + val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate) + val endMin = if (localDate(event.endDate).isAfter(day)) 24 * 60 else minutesOfDay(event.endDate) + val duration = (endMin - startMin).coerceAtLeast(30) + val top = hourHeight * (startMin / 60f) + val height = (hourHeight * (duration / 60f)) + val color = colorFromHex(event.effectiveColor) + val lang = LocalLang.current + + Box( + modifier = Modifier + .offset(y = top) + .columnSlot(index, dayCount) + .height(height) + .padding(horizontal = 1.dp, vertical = 0.5.dp) + .alpha(if (dimmed) 0.45f else 1f) + .clip(RoundedCornerShape(4.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + EventLabel( + event = event, + color = color.contrastingTextColor(), + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + maxLines = 2, + ) + if (height > 36.dp) { + Text( + eventTimeRange(event, lang), + fontSize = 8.sp, + color = color.contrastingTextColor().copy(alpha = 0.85f), + ) + } + } + } +} + +/** Position a child in column [index] of [dayCount] columns, after the gutter. */ +private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { measurable, constraints -> + val gutterPx = GUTTER.roundToPx() + val available = (constraints.maxWidth - gutterPx).coerceAtLeast(0) + val colWidth = available / dayCount + val placeable = measurable.measure( + constraints.copy(minWidth = colWidth, maxWidth = colWidth) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(gutterPx + colWidth * index, 0) + } +} + +@Composable +private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + Box( + Modifier + .fillMaxWidth() + .padding(vertical = 1.dp) + .alpha(if (dimmed) 0.45f else 1f) + .clip(RoundedCornerShape(3.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) { + EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp) + } +} + +@Composable +fun DayView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) { + TimeGridView(listOf(state.currentDate), state, vm, onEventClick) +} + +@Composable +fun WeekView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) { + val mondayFirst = state.weekStartsOnMonday + val dow = state.currentDate.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + val weekStart = state.currentDate.minusDays(offset.toLong()) + val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } + TimeGridView(days, state, vm, onEventClick) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt new file mode 100644 index 0000000..4132d12 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt @@ -0,0 +1,152 @@ +package com.scarriffle.calendarr.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.toHex + +/** + * Full HSV colour picker: saturation/value field + hue slider + hex input. + * Returns a "#RRGGBB" string via [onConfirm]. + */ +@Composable +fun ColorPickerDialog( + initial: String, + title: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val initHsv = remember(initial) { + FloatArray(3).also { android.graphics.Color.colorToHSV(colorFromHex(initial).toArgb(), it) } + } + var hue by remember { mutableFloatStateOf(initHsv[0]) } + var sat by remember { mutableFloatStateOf(initHsv[1]) } + var value by remember { mutableFloatStateOf(initHsv[2]) } + var hexText by remember { mutableStateOf(colorFromHex(initial).toHex()) } + + val current = Color.hsv(hue, sat, value) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.fillMaxWidth()) { + // Saturation / Value field + Box( + Modifier + .fillMaxWidth() + .height(170.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Brush.horizontalGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + .pointerInput(Unit) { + detectTapGestures { o -> + sat = (o.x / size.width).coerceIn(0f, 1f) + value = (1f - o.y / size.height).coerceIn(0f, 1f) + hexText = Color.hsv(hue, sat, value).toHex() + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + sat = (change.position.x / size.width).coerceIn(0f, 1f) + value = (1f - change.position.y / size.height).coerceIn(0f, 1f) + hexText = Color.hsv(hue, sat, value).toHex() + } + }, + ) { + Canvas(Modifier.matchParentSize()) { + val cx = sat * size.width + val cy = (1f - value) * size.height + drawCircle(Color.White, radius = 9f, center = Offset(cx, cy), style = Stroke(width = 3f)) + drawCircle(Color.Black, radius = 12f, center = Offset(cx, cy), style = Stroke(width = 1f)) + } + } + Spacer(Modifier.height(14.dp)) + + // Hue slider + Box( + Modifier + .fillMaxWidth() + .height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(Brush.horizontalGradient((0..6).map { Color.hsv(it * 60f, 1f, 1f) })) + .pointerInput(Unit) { + detectTapGestures { o -> + hue = (o.x / size.width * 360f).coerceIn(0f, 360f) + hexText = Color.hsv(hue, sat, value).toHex() + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + hue = (change.position.x / size.width * 360f).coerceIn(0f, 360f) + hexText = Color.hsv(hue, sat, value).toHex() + } + }, + ) { + Canvas(Modifier.matchParentSize()) { + val cx = (hue / 360f) * size.width + drawCircle(Color.White, radius = 10f, center = Offset(cx, size.height / 2f), style = Stroke(width = 3f)) + } + } + Spacer(Modifier.height(14.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(40.dp).clip(CircleShape).background(current).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = hexText, + onValueChange = { raw -> + hexText = raw + val parsed = colorFromHex(raw, Color.Unspecified) + if (parsed != Color.Unspecified) { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(parsed.toArgb(), hsv) + hue = hsv[0]; sat = hsv[1]; value = hsv[2] + } + }, + label = { Text("Hex") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { TextButton(onClick = { onConfirm(current.toHex()) }) { Text("OK") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt new file mode 100644 index 0000000..dec4292 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt @@ -0,0 +1,88 @@ +package com.scarriffle.calendarr.ui.components + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.delay + +/** + * Password input with two affordances the plain field lacks: + * - an eye toggle to reveal the full value, and + * - a brief reveal of the most recently typed character (~1.2s) so the user + * can tell whether a keystroke landed as intended. + */ +@Composable +fun PasswordField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Done, +) { + var fullyVisible by remember { mutableStateOf(false) } + var revealLast by remember { mutableStateOf(false) } + + // Reveal the last character briefly after each change. + LaunchedEffect(value) { + if (value.isNotEmpty() && !fullyVisible) { + revealLast = true + delay(1200) + revealLast = false + } else { + revealLast = false + } + } + + val transformation = when { + fullyVisible -> VisualTransformation.None + revealLast -> LastCharVisibleTransformation + else -> PasswordVisualTransformation() + } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + singleLine = true, + visualTransformation = transformation, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction), + trailingIcon = { + IconButton(onClick = { fullyVisible = !fullyVisible }) { + Icon( + imageVector = if (fullyVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = null, + ) + } + }, + modifier = modifier, + ) +} + +/** Masks every character except the last with a bullet. 1:1 char mapping → identity offsets. */ +private val LastCharVisibleTransformation = object : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val masked = buildString { + for (i in text.indices) append(if (i == text.lastIndex) text[i] else '•') + } + return TransformedText(AnnotatedString(masked), OffsetMapping.Identity) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt new file mode 100644 index 0000000..893cb78 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -0,0 +1,168 @@ +package com.scarriffle.calendarr.ui.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +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.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.eventDateRange +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +/** Full-screen event detail (mirrors the iOS detail page). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailScreen( + event: CalEvent, + currentUserId: Int = 0, + onClose: () -> Unit, + onEdit: () -> Unit, + onCopy: () -> Unit, + onDelete: () -> Unit, +) { + val lang = LocalLang.current + var confirmDelete by remember { mutableStateOf(false) } + val canEdit = event.source in setOf("local", "caldav", "homeassistant", "google") + val canDelete = canEdit + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("event.detail_title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + actions = { + if (canEdit) { + TextButton(onClick = onEdit) { Text(tr("event.edit_title")) } + } + }, + ) + }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Box( + Modifier.width(6.dp).height(46.dp).clip(RoundedCornerShape(3.dp)) + .background(colorFromHex(event.effectiveColor)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(event.renderTitle, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + if (event.calendarName.isNotBlank()) { + Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + Spacer(Modifier.height(20.dp)) + + DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) + if (event.location.isNotBlank()) DetailRow(Icons.Filled.LocationOn, event.location) + if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes) + if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName) + DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() }) + event.creator?.let { c -> + if (c.id != currentUserId) { + DetailRow(Icons.Filled.Person, "${tr("event.created_by")}: ${c.displayName}") + } + } + if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private")) + + Spacer(Modifier.height(28.dp)) + OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.ContentCopy, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(tr("event.copy_title")) + } + if (canDelete) { + Spacer(Modifier.height(4.dp)) + // Deliberately low-key (plain text button): destructive action + // should be findable, not the loudest element on the screen. + TextButton( + onClick = { confirmDelete = true }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(tr("common.delete")) + } + } + Spacer(Modifier.height(24.dp)) + } + } + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("common.delete")) }, + text = { Text(tr("event.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDelete() }) { + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } }, + ) + } +} + +@Composable +private fun DetailRow(icon: ImageVector, text: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.Top) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(14.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt new file mode 100644 index 0000000..73cf958 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -0,0 +1,407 @@ +package com.scarriffle.calendarr.ui.event + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.IconButton +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.ReminderOptions +import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.EditorRequest +import com.scarriffle.calendarr.ui.calendar.calendarKey +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val PRESET_COLORS = listOf( + "#4285f4", "#ea4335", "#34a853", "#fbbc05", + "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventEditorSheet( + request: EditorRequest, + writableCalendars: List, + onDismiss: () -> Unit, + defaultDurationMinutes: Int = 60, + reminderDisabledKeys: Set = emptySet(), + onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List) -> Unit, +) { + val zone = ZoneId.systemDefault() + val context = LocalContext.current + val lang = LocalLang.current + val existing = request.existing + // Fields are prefilled from the event being edited OR copied. + val template = existing ?: request.prefill + val isCopy = existing == null && request.prefill != null + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var title by remember { mutableStateOf(template?.title ?: "") } + var allDay by remember { mutableStateOf(template?.isAllDay ?: false) } + var location by remember { mutableStateOf(template?.location ?: "") } + var description by remember { mutableStateOf(template?.notes ?: "") } + var color by remember { mutableStateOf(template?.color) } + var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) } + + val initialStart = template?.startDate + val initialEnd = template?.endDate + var startDate by remember { + mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date) + } + var startTime by remember { + mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0)) + } + // New events default to start (09:00) + the user's default duration. + val defaultEnd = request.date.atTime(9, 0).plusMinutes(defaultDurationMinutes.toLong()) + var endDate by remember { + mutableStateOf( + initialEnd?.let { + val d = LocalDate.ofInstant(it, zone) + if (template?.isAllDay == true) d.minusDays(1) else d + } ?: defaultEnd.toLocalDate() + ) + } + var endTime by remember { + mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: defaultEnd.toLocalTime()) + } + var reminders by remember { mutableStateOf(template?.reminders ?: emptyList()) } + + val preselected = template?.let { ev -> + val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull() + writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id } + } + var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) } + var calMenuOpen by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy") + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + fun pickDate(current: LocalDate, onPicked: (LocalDate) -> Unit) { + DatePickerDialog( + context, + { _, y, m, d -> onPicked(LocalDate.of(y, m + 1, d)) }, + current.year, current.monthValue - 1, current.dayOfMonth, + ).show() + } + + fun pickTime(current: LocalTime, onPicked: (LocalTime) -> Unit) { + TimePickerDialog( + context, + { _, h, min -> onPicked(LocalTime.of(h, min)) }, + current.hour, current.minute, true, + ).show() + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + ) { + Text( + when { + existing != null -> tr("event.edit_title") + isCopy -> tr("event.copy_title") + else -> tr("event.new_title") + }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 8.dp), + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it; error = null }, + label = { Text(tr("event.title_placeholder")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("event.allday"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = allDay, onCheckedChange = { allDay = it }) + } + Spacer(Modifier.size(8.dp)) + + // Start + Text(tr("event.start"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pickDate(startDate) { startDate = it; if (endDate.isBefore(it)) endDate = it } }) { + Text(dateFmt.format(startDate)) + } + if (!allDay) { + OutlinedButton(onClick = { pickTime(startTime) { startTime = it } }) { Text(timeFmt.format(startTime)) } + } + } + Spacer(Modifier.size(8.dp)) + + // End + Text(tr("event.end"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pickDate(endDate) { endDate = it } }) { Text(dateFmt.format(endDate)) } + if (!allDay) { + OutlinedButton(onClick = { pickTime(endTime) { endTime = it } }) { Text(timeFmt.format(endTime)) } + } + } + Spacer(Modifier.size(12.dp)) + + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text(tr("event.location")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(tr("event.description")) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + + // Calendar picker + Text(tr("event.calendar_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (writableCalendars.isEmpty()) { + Text(tr("event.no_writable"), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } else { + Box { + OutlinedButton(onClick = { calMenuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(calendar?.color))) + Spacer(Modifier.size(8.dp)) + Text(calendar?.name ?: tr("event.calendar_picker"), modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = calMenuOpen, onDismissRequest = { calMenuOpen = false }) { + writableCalendars.forEach { c -> + DropdownMenuItem( + text = { Text(c.name) }, + leadingIcon = { Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(c.color))) }, + onClick = { calendar = c; calMenuOpen = false }, + ) + } + } + } + } + Spacer(Modifier.size(12.dp)) + + // Private + reminders (local calendars only) + if (calendar?.source == "local") { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = isPrivate, onCheckedChange = { isPrivate = it }) + } + Spacer(Modifier.size(12.dp)) + + val remindersDisabled = calendar?.let { + reminderDisabledKeys.contains(calendarKey(it.source, it.numericId.toString())) + } ?: false + Text(tr("event.reminders"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (remindersDisabled) { + Text( + tr("event.reminders_disabled"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 2.dp), + ) + } + reminders.forEachIndexed { idx, min -> + ReminderRow( + minutes = min, + enabled = !remindersDisabled, + onChange = { v -> reminders = reminders.toMutableList().also { it[idx] = v } }, + onRemove = { reminders = reminders.toMutableList().also { it.removeAt(idx) } }, + ) + } + androidx.compose.material3.TextButton( + enabled = !remindersDisabled, + onClick = { + val next = ReminderOptions.presets.firstOrNull { it !in reminders } ?: ReminderOptions.customDefault + reminders = reminders + next + }, + ) { Text(tr("event.reminder_add")) } + Spacer(Modifier.size(12.dp)) + } + + // Color + Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PRESET_COLORS.forEach { hex -> + val selected = color?.equals(hex, ignoreCase = true) == true + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .background(colorFromHex(hex)) + .then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .clickable { color = hex }, + contentAlignment = Alignment.Center, + ) { + if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + if (color != null) { + androidx.compose.material3.TextButton(onClick = { color = null }) { Text(tr("event.reset_color")) } + } + + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp)) + } + Spacer(Modifier.size(16.dp)) + Button( + onClick = { + val cal = calendar + if (title.isBlank()) { error = L10n.t("event.title_placeholder", lang); return@Button } + if (cal == null) { error = L10n.t("event.no_writable", lang); return@Button } + val start: Instant + val end: Instant + if (allDay) { + start = startDate.atStartOfDay(zone).toInstant() + end = endDate.plusDays(1).atStartOfDay(zone).toInstant() + } else { + start = startDate.atTime(startTime).atZone(zone).toInstant() + end = endDate.atTime(endTime).atZone(zone).toInstant() + } + val rem = if (cal.source == "local") reminders else emptyList() + onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem) + }, + enabled = writableCalendars.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (existing == null) tr("event.add") else tr("event.save")) + } + } + } +} + +/** One reminder row: a preset dropdown plus, in custom mode, a number field + + * unit dropdown. The value is always emitted as minutes-before-start. */ +@Composable +private fun ReminderRow( + minutes: Int, + enabled: Boolean, + onChange: (Int) -> Unit, + onRemove: () -> Unit, +) { + val isPreset = minutes in ReminderOptions.presets + var presetMenu by remember { mutableStateOf(false) } + var unitMenu by remember { mutableStateOf(false) } + + @Composable + fun label(min: Int): String = + if (min == 0) tr("event.reminder_at_start") + else ReminderOptions.split(min).let { (v, u) -> "$v ${tr(u.labelKey)} ${tr("event.reminder_before")}" } + + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + OutlinedButton(onClick = { presetMenu = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text(if (isPreset) label(minutes) else tr("event.reminder_custom"), modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = presetMenu, onDismissRequest = { presetMenu = false }) { + ReminderOptions.presets.forEach { p -> + DropdownMenuItem(text = { Text(label(p)) }, onClick = { onChange(p); presetMenu = false }) + } + DropdownMenuItem(text = { Text(tr("event.reminder_custom")) }, onClick = { + if (minutes in ReminderOptions.presets) onChange(ReminderOptions.customDefault) + presetMenu = false + }) + } + } + IconButton(onClick = onRemove, enabled = enabled) { + Icon(Icons.Filled.Close, contentDescription = null) + } + } + if (!isPreset) { + val (value, unit) = ReminderOptions.split(minutes) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = value.toString(), + onValueChange = { txt -> + val n = txt.filter { it.isDigit() }.toIntOrNull()?.coerceAtLeast(1) ?: 1 + onChange(n * unit.mult) + }, + enabled = enabled, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(96.dp), + ) + Box { + OutlinedButton(onClick = { unitMenu = true }, enabled = enabled) { + Text(tr(unit.labelKey)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = unitMenu, onDismissRequest = { unitMenu = false }) { + ReminderOptions.Unit.values().forEach { u -> + DropdownMenuItem(text = { Text(tr(u.labelKey)) }, onClick = { onChange(value * u.mult); unitMenu = false }) + } + } + } + Text(tr("event.reminder_before"), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt new file mode 100644 index 0000000..92cfb05 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt @@ -0,0 +1,365 @@ +package com.scarriffle.calendarr.ui.groups + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Celebration +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DirectionsRun +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Pets +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Work +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.ui.components.ColorPickerDialog +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +/** + * Cross-platform group-icon keys (stored server-side) rendered as native + * Material icons — consistent everywhere instead of OS-emoji that vary by + * platform. Mirrors iOS GroupIcons / the web SVG set. + */ +object GroupIcons { + val keys = listOf( + "people", "home", "heart", "work", "school", "sports", + "party", "pet", "travel", "music", "food", "star", + ) + + fun vector(key: String?): ImageVector = when (key) { + "people" -> Icons.Filled.People + "home" -> Icons.Filled.Home + "heart" -> Icons.Filled.Favorite + "work" -> Icons.Filled.Work + "school" -> Icons.Filled.School + "sports" -> Icons.Filled.DirectionsRun + "party" -> Icons.Filled.Celebration + "pet" -> Icons.Filled.Pets + "travel" -> Icons.Filled.Flight + "music" -> Icons.Filled.MusicNote + "food" -> Icons.Filled.Restaurant + "star" -> Icons.Filled.Star + else -> Icons.Filled.People + } + + fun isKey(s: String?): Boolean = s != null && s in keys +} + +/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */ +@Composable +fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) { + if (GroupIcons.isKey(icon)) { + Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier, + tint = tint ?: androidx.compose.material3.LocalContentColor.current) + } else if (!icon.isNullOrEmpty()) { + Text(icon, modifier = modifier) + } else { + Icon(Icons.Filled.People, contentDescription = null, modifier = modifier, + tint = tint ?: androidx.compose.material3.LocalContentColor.current) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupsScreen( + onClose: () -> Unit, + onChanged: () -> Unit, + onOpenGroupView: (Group) -> Unit = {}, + vm: GroupsViewModel = hiltViewModel(), +) { + var createOpen by remember { mutableStateOf(false) } + var manageId by remember { mutableStateOf(null) } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("groups.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + actions = { + IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + if (vm.groups.isEmpty()) { + item { + Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp)) + } + } + items(vm.groups, key = { it.id }) { g -> + Row( + Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface) + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text(g.name, style = MaterialTheme.typography.bodyLarge) + Text( + tr("groups.member_count", g.memberCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { manageId = g.id }) { + Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Divider() + } + vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } + } + } + } + + if (createOpen) { + GroupEditSheet( + vm = vm, + existing = null, + onDismiss = { createOpen = false }, + onSaved = { createOpen = false; onChanged() }, + ) + } + + manageId?.let { id -> + val existing = vm.groups.firstOrNull { it.id == id } + GroupEditSheet( + vm = vm, + existing = existing, + onDismiss = { manageId = null }, + onSaved = { manageId = null; onChanged() }, + ) + } +} + +/** Create (existing == null) or manage (existing != null) a group. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun GroupEditSheet( + vm: GroupsViewModel, + existing: Group?, + onDismiss: () -> Unit, + onSaved: () -> Unit, +) { + val me = vm.currentUserId + var name by remember { mutableStateOf(existing?.name ?: "") } + var icon by remember { mutableStateOf(existing?.icon?.takeIf { GroupIcons.isKey(it) } ?: "people") } + var selected by remember { mutableStateOf(setOf()) } + var existingMembers by remember { mutableStateOf(setOf()) } + var detail by remember { mutableStateOf(null) } + var loaded by remember { mutableStateOf(existing == null) } + var confirmDelete by remember { mutableStateOf(false) } + var memberColorTarget by remember { mutableStateOf?>(null) } // userId, current hex + + // Manage: load full details (members + colours) and pre-fill selection. + LaunchedEffect(existing?.id) { + val id = existing?.id ?: return@LaunchedEffect + val g = vm.groupDetail(id) + detail = g + if (g != null) { + name = g.name + icon = g.icon?.takeIf { GroupIcons.isKey(it) } ?: "people" + val members = g.members.map { it.id }.filter { it != me }.toSet() + existingMembers = members + selected = members + } + loaded = true + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) { + Text( + if (existing == null) tr("groups.create") else tr("groups.manage"), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.size(16.dp)) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(tr("groups.name")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(16.dp)) + + Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + GroupIcons.keys.forEach { ic -> + val sel = ic == icon + Box( + Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)) + .background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant) + .clickable { icon = ic }, + contentAlignment = Alignment.Center, + ) { + Icon( + GroupIcons.vector(ic), + contentDescription = ic, + tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.size(16.dp)) + + Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (!loaded) { + Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) } + } else { + vm.directory.forEach { u -> + Row( + Modifier.fillMaxWidth().clickable { + selected = if (u.id in selected) selected - u.id else selected + u.id + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = u.id in selected, onCheckedChange = { + selected = if (u.id in selected) selected - u.id else selected + u.id + }) + Text(u.displayName, modifier = Modifier.padding(start = 4.dp)) + } + } + } + + // Member colours (manage only) + detail?.members?.takeIf { it.isNotEmpty() }?.let { members -> + Spacer(Modifier.size(8.dp)) + Divider() + Spacer(Modifier.size(8.dp)) + Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + members.forEach { m -> + val hex = m.color ?: "#4285f4" + Row( + Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex))) + Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp)) + } + } + } + + Spacer(Modifier.size(20.dp)) + Button(onClick = { + val n = name.trim() + if (n.isEmpty()) return@Button + if (existing == null) { + vm.createGroup(n, icon, selected.toList()) { onSaved() } + } else { + vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() } + } + }, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text(tr("event.save")) + } + + if (existing != null) { + Spacer(Modifier.size(8.dp)) + OutlinedButton( + onClick = { confirmDelete = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.size(8.dp)) + Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } + + memberColorTarget?.let { (userId, hex) -> + ColorPickerDialog( + initial = hex, + title = tr("groups.member_color"), + onDismiss = { memberColorTarget = null }, + onConfirm = { picked -> + existing?.let { vm.setMemberColor(it.id, userId, picked) } + // reflect locally + detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) } + memberColorTarget = null + }, + ) + } + + if (confirmDelete && existing != null) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("groups.delete")) }, + text = { Text(tr("groups.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) { + Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } }, + ) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt new file mode 100644 index 0000000..895005c --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt @@ -0,0 +1,77 @@ +package com.scarriffle.calendarr.ui.groups + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.domain.model.DirectoryUser +import com.scarriffle.calendarr.domain.model.Group +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GroupsViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var loading by mutableStateOf(true) + private set + var groups by mutableStateOf>(emptyList()) + private set + var directory by mutableStateOf>(emptyList()) + private set + var error by mutableStateOf(null) + private set + + val currentUserId: Int get() = repository.currentUserId + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + error = null + groups = runCatching { repository.getGroups() }.getOrDefault(emptyList()) + directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList()) + loading = false + } + } + + suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull() + + fun createGroup(name: String, icon: String, memberIds: List, onDone: () -> Unit) { + viewModelScope.launch { + runCatching { repository.createGroup(name, memberIds, icon) } + .onSuccess { load(); onDone() } + .onFailure { error = it.message } + } + } + + /** Save name/icon, then reconcile membership against [existingMemberIds]. */ + fun saveGroup(id: Int, name: String, icon: String, desired: Set, existing: Set, onDone: () -> Unit) { + viewModelScope.launch { + runCatching { + repository.updateGroup(id, name, icon) + for (uid in desired - existing) repository.addGroupMember(id, uid) + for (uid in existing - desired) repository.removeGroupMember(id, uid) + } + .onSuccess { load(); onDone() } + .onFailure { error = it.message } + } + } + + fun setMemberColor(groupId: Int, userId: Int, color: String) { + viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } } + } + + fun deleteGroup(id: Int, onDone: () -> Unit) { + viewModelScope.launch { + runCatching { repository.deleteGroup(id) } + .onSuccess { load(); onDone() } + .onFailure { error = it.message } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt new file mode 100644 index 0000000..8e9e52f --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuScreen.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..d81c160 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt @@ -0,0 +1,163 @@ +package com.scarriffle.calendarr.ui.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.components.PasswordField +import com.scarriffle.calendarr.ui.tr + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen(onClose: () -> Unit, vm: ProfileViewModel = hiltViewModel()) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("profile.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + } + return@Scaffold + } + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), + ) { + val p = vm.profile + SectionTitle(tr("profile.account")) + InfoRow(tr("profile.username"), p?.username ?: "—") + InfoRow(tr("profile.role"), if (p?.isAdmin == true) tr("profile.role.admin") else tr("profile.role.user")) + Spacer(Modifier.size(20.dp)) + + SectionTitle(tr("profile.email")) + OutlinedTextField( + value = vm.email, + onValueChange = vm::onEmailChange, + label = { Text(tr("profile.email")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + Button(onClick = vm::saveEmail) { Text(tr("profile.save_email")) } + + Spacer(Modifier.size(24.dp)) + Divider() + Spacer(Modifier.size(16.dp)) + PasswordSection(vm) + + Spacer(Modifier.size(24.dp)) + Divider() + Spacer(Modifier.size(16.dp)) + TwoFactorSection(vm) + + vm.message?.let { + Spacer(Modifier.size(16.dp)) + Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +private fun PasswordSection(vm: ProfileViewModel) { + val mismatchMsg = tr("profile.password_mismatch") + var current by remember { mutableStateOf("") } + var new1 by remember { mutableStateOf("") } + var new2 by remember { mutableStateOf("") } + var localError by remember { mutableStateOf(null) } + + SectionTitle(tr("profile.change_password")) + PasswordField(value = current, onValueChange = { current = it }, label = tr("profile.current_password"), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + PasswordField(value = new1, onValueChange = { new1 = it }, label = tr("profile.new_password"), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + PasswordField(value = new2, onValueChange = { new2 = it }, label = tr("profile.new_password_repeat"), modifier = Modifier.fillMaxWidth()) + localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + Spacer(Modifier.size(8.dp)) + Button(onClick = { + if (new1 != new2) { localError = mismatchMsg; return@Button } + localError = null + vm.changePassword(current, new1) { ok -> if (ok) { current = ""; new1 = ""; new2 = "" } } + }) { Text(tr("profile.change_password")) } +} + +@Composable +private fun TwoFactorSection(vm: ProfileViewModel) { + val enabled = vm.profile?.totpEnabled == true + var disablePw by remember { mutableStateOf("") } + SectionTitle(tr("profile.twofa")) + Text(if (enabled) tr("profile.twofa.active") else tr("profile.twofa.inactive"), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.size(8.dp)) + if (enabled) { + PasswordField(value = disablePw, onValueChange = { disablePw = it }, label = tr("twofa.password_placeholder"), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) } + } else { + val setup = vm.totpSetup + if (setup == null) { + Button(onClick = vm::begin2fa) { Text(tr("profile.twofa.enable")) } + } else { + Text(tr("twofa.scan_hint"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(8.dp)) + Text(setup.secret, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(value = vm.totpCode, onValueChange = vm::onTotpChange, label = { Text(tr("twofa.code_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { + Button(onClick = vm::confirm2fa) { Text(tr("twofa.activate")) } + OutlinedButton(onClick = vm::cancel2fa) { Text(tr("common.cancel")) } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, fontWeight = FontWeight.Medium) + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..4e3e084 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt @@ -0,0 +1,91 @@ +package com.scarriffle.calendarr.ui.profile + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.TotpSetup +import com.scarriffle.calendarr.domain.model.UserProfile +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var profile by mutableStateOf(null) + private set + var email by mutableStateOf("") + private set + var loading by mutableStateOf(true) + private set + var message by mutableStateOf(null) + private set + + // 2FA enrolment + var totpSetup by mutableStateOf(null) + private set + var totpCode by mutableStateOf("") + private set + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + runCatching { repository.getProfile() } + .onSuccess { p -> profile = p; email = p.email ?: "" } + .onFailure { message = it.message } + loading = false + } + } + + fun onEmailChange(v: String) { email = v } + fun onTotpChange(v: String) { totpCode = v } + + fun saveEmail() { + viewModelScope.launch { + runCatching { repository.updateEmail(email.trim()) } + .onSuccess { message = "✓"; load() } + .onFailure { message = it.message } + } + } + + fun changePassword(current: String, new: String, onDone: (Boolean) -> Unit) { + viewModelScope.launch { + runCatching { repository.changePassword(current, new) } + .onSuccess { message = "✓"; onDone(true) } + .onFailure { message = it.message; onDone(false) } + } + } + + fun begin2fa() { + viewModelScope.launch { + runCatching { repository.setup2fa() } + .onSuccess { totpSetup = it } + .onFailure { message = it.message } + } + } + + fun confirm2fa() { + viewModelScope.launch { + runCatching { repository.enable2fa(totpCode.trim()) } + .onSuccess { totpSetup = null; totpCode = ""; load() } + .onFailure { message = it.message } + } + } + + fun disable2fa(password: String) { + viewModelScope.launch { + runCatching { repository.disable2fa(password) } + .onSuccess { load() } + .onFailure { message = it.message } + } + } + + fun cancel2fa() { totpSetup = null; totpCode = "" } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..818339c --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -0,0 +1,432 @@ +package com.scarriffle.calendarr.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.components.ColorPickerDialog +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.toHex + +// Canonical default colours (single source for the reset buttons). +private val DEFAULT_COLORS = mapOf( + "primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4", + "text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52", + "month_divider_color" to "#7090C0", "month_label_color" to "#7090C0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onClose: () -> Unit, + onSettingsChanged: (AppSettings) -> Unit, + onSettingsSynced: () -> Unit, + vm: SettingsViewModel = hiltViewModel(), +) { + val initialSettings = LocalAppSettings.current + var settings by remember { mutableStateOf(initialSettings) } + + fun update(newSettings: AppSettings) { + settings = newSettings + onSettingsChanged(newSettings) + vm.apply(newSettings, onSettingsSynced) + } + + // Per-row sync toggle: enabling adopts this device's current value. + fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) } + fun synced(key: String) = vm.syncFlags[key] == true + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("settings.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + + // Global "sync everything" master switch. + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = vm.syncableKeys.all { vm.syncFlags[it] == true }, + onCheckedChange = { on -> vm.setAllSync(on, settings, onSettingsSynced) }, + ) + } + Divider(Modifier.padding(vertical = 16.dp)) + + ProfileChapter(vm) + Divider(Modifier.padding(vertical = 16.dp)) + + // ---- Termine (synced) ---- + Section(tr("settings.calview")) + SyncDropdownRow( + tr("settings.default_duration"), + listOf("15" to "15 min", "30" to "30 min", "45" to "45 min", "60" to "1 h", "90" to "1.5 h", "120" to "2 h"), + settings.defaultEventDurationMinutes.toString(), + { update(settings.copy(defaultEventDurationMinutes = it.toInt())) }, + synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") }, + ) + SyncDropdownRow( + tr("settings.defaultreminder"), + reminderOptions(), + (settings.defaultReminderMinutes ?: -1).toString(), + { update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) }, + synced("default_reminder_minutes"), { toggle("default_reminder_minutes") }, + ) + Divider(Modifier.padding(vertical = 16.dp)) + + // ---- Ansicht (synced) ---- + Section(tr("settings.appearance")) + SyncDropdownRow( + tr("settings.defaultview"), + CalViewType.entries.map { it.key to tr("view.${it.key}") }, + settings.defaultView, + { update(settings.copy(defaultView = it)) }, + synced("default_view"), { toggle("default_view") }, + ) + SyncDropdownRow( + tr("settings.firstweekday"), + listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), + settings.weekStartDay, + { update(settings.copy(weekStartDay = it)) }, + synced("week_start_day"), { toggle("week_start_day") }, + ) + SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") }) + SyncDropdownRow( + tr("settings.month_mode"), + listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")), + settings.monthViewPaged.toString(), + { update(settings.copy(monthViewPaged = it.toBoolean())) }, + synced("month_view_paged"), { toggle("month_view_paged") }, + ) + SyncDropdownRow( + tr("settings.hourheight"), + listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")), + settings.hourHeight.toString(), + { update(settings.copy(hourHeight = it.toInt())) }, + synced("hour_height"), { toggle("hour_height") }, + ) + Divider(Modifier.padding(vertical = 16.dp)) + + // ---- Farben (synced) ---- + Section(tr("settings.colors")) + SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) } + SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) } + SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) } + SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) } + SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) } + SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) } + SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) } + SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) } + Divider(Modifier.padding(vertical = 16.dp)) + + // ---- Cache (synced) ---- + SyncDropdownRow( + tr("settings.cache.range"), + listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")), + settings.cacheMonths.toString(), + { update(settings.copy(cacheMonths = it.toInt())) }, + synced("cache_months"), { toggle("cache_months") }, + ) + Divider(Modifier.padding(vertical = 16.dp)) + + // ---- Gerät (device-local: language + contrast) ---- + Section(tr("settings.device")) + DeviceDropdownRow( + tr("settings.language"), + listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")), + settings.language, + ) { update(settings.copy(language = it)) } + DeviceDropdownRow( + tr("settings.textcontrast"), + listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")), + settings.textContrast.toString(), + ) { update(settings.copy(textContrast = it.toInt())) } + DeviceDropdownRow( + tr("settings.linecontrast"), + listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")), + settings.lineContrast.toString(), + ) { update(settings.copy(lineContrast = it.toInt())) } + var hideMenu by remember { mutableStateOf(vm.hideMenuButton) } + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.size(44.dp)) + Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it }) + } + Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp)) + Spacer(Modifier.size(40.dp)) + } + } + } +} + +@Composable +private fun reminderOptions(): List> = listOf( + "-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min", + "30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"), +) + +@Composable +private fun Section(title: String) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) +} + + +/** 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>, 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>, + 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>, + selected: String, + onSelect: (String) -> Unit, +) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.size(44.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Dropdown(options, selected, onSelect) + } +} + +@Composable +private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + SyncIcon(synced, onToggleSync) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} + +@Composable +private fun SyncColorRow( + syncKey: String, + label: String, + current: String, + synced: Boolean, + onToggleSync: () -> Unit, + onPick: (String) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + SyncIcon(synced, onToggleSync) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(12.dp)) + Box( + Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .clickable { showPicker = true }, + ) + TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) { + Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall) + } + } + if (showPicker) { + ColorPickerDialog( + initial = current, + title = label, + onDismiss = { showPicker = false }, + onConfirm = { showPicker = false; onPick(it) }, + ) + } +} + +/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfileChapter(vm: SettingsViewModel) { + val savedLabel = tr("settings.saved") + + Section(tr("settings.nav.profile")) + OutlinedTextField( + value = vm.displayName, + onValueChange = vm::onDisplayNameChange, + label = { Text(tr("profile.display_name")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(tr("profile.login_name"), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(vm.loginName, fontWeight = FontWeight.Medium) + } + Spacer(Modifier.size(8.dp)) + OutlinedTextField( + value = vm.email, + onValueChange = vm::onEmailChange, + label = { Text(tr("profile.email")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge) + Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange) + } + Spacer(Modifier.size(8.dp)) + Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) } + vm.profileMessage?.let { + Spacer(Modifier.size(8.dp)) + Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) + } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.privacy")) + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Dropdown( + listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")), + vm.privateVisibility, + vm::changePrivateVisibility, + ) + } + Spacer(Modifier.size(6.dp)) + Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.group_visible")) + CalendarDropdown(vm) + Spacer(Modifier.size(6.dp)) + Text(tr("settings.group_visible.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CalendarDropdown(vm: SettingsViewModel) { + var expanded by remember { mutableStateOf(false) } + val noneLabel = tr("group.visible.none") + val selectedLabel = vm.ownLocalCalendars.firstOrNull { it.id == vm.groupVisibleId }?.name ?: noneLabel + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + label = { Text(tr("settings.group_visible")) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(text = { Text(noneLabel) }, onClick = { vm.changeGroupVisible(0); expanded = false }) + vm.ownLocalCalendars.forEach { cal -> + DropdownMenuItem(text = { Text(cal.name) }, onClick = { vm.changeGroupVisible(cal.id); expanded = false }) + } + } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..f81594e --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt @@ -0,0 +1,124 @@ +package com.scarriffle.calendarr.ui.settings + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.LocalCalendar +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: CalendarRepository, + private val settingsStore: SettingsStore, +) : ViewModel() { + + /** Persist locally immediately, then push the synced values to the server. */ + fun apply(settings: AppSettings, onSynced: () -> Unit) { + settingsStore.saveSettings(settings) + viewModelScope.launch { + runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) } + onSynced() + } + } + + /** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */ + var hideMenuButton: Boolean + get() = settingsStore.hideMenuButton + set(value) { settingsStore.hideMenuButton = value } + + // ---- Per-setting sync flags ---- + + val syncableKeys: List get() = settingsStore.syncableKeys + var syncFlags by mutableStateOf(settingsStore.loadSyncFlags()) + private set + + /** Toggle one setting's flag. Enabling adopts this device's current value + * (pushes the synced values up); disabling keeps the value local. */ + fun toggleSync(key: String, current: AppSettings, onSynced: () -> Unit) { + settingsStore.setSyncFlag(key, !(syncFlags[key] ?: false)) + syncFlags = settingsStore.loadSyncFlags() + viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() } + } + + /** Global "sync everything" switch. */ + fun setAllSync(on: Boolean, current: AppSettings, onSynced: () -> Unit) { + settingsStore.setAllSyncFlags(on) + syncFlags = settingsStore.loadSyncFlags() + viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() } + } + + // ---- Profile chapter (server-backed) ---- + + var displayName by mutableStateOf("") + var loginName by mutableStateOf("") + var email by mutableStateOf("") + private set + var directoryHidden by mutableStateOf(false) + private set + var privateVisibility by mutableStateOf("busy") + private set + var groupVisibleId by mutableStateOf(0) // 0 = none + private set + var ownLocalCalendars by mutableStateOf>(emptyList()) + private set + var profileMessage by mutableStateOf(null) + private set + + init { loadProfile() } + + fun onDisplayNameChange(v: String) { displayName = v } + fun onEmailChange(v: String) { email = v } + fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v } + + private fun loadProfile() { + viewModelScope.launch { + runCatching { repository.getProfile() }.onSuccess { p -> + displayName = p.displayName ?: p.username + loginName = p.username + email = p.email ?: "" + directoryHidden = p.directoryHidden + } + runCatching { repository.getSettings() }.onSuccess { s -> + privateVisibility = s.privateEventVisibility + groupVisibleId = s.groupVisibleCalendarId ?: 0 + } + runCatching { repository.getLocalCalendars() }.onSuccess { cals -> + // 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 } + } + } + } + + fun saveProfile(savedLabel: String) { + viewModelScope.launch { + runCatching { + repository.updateProfile( + displayName = displayName.trim().ifEmpty { null }, + username = null, + email = email.trim(), + directoryHidden = directoryHidden, + ) + } + .onSuccess { profileMessage = savedLabel } + .onFailure { profileMessage = it.message } + } + } + + fun changePrivateVisibility(value: String) { + privateVisibility = value + viewModelScope.launch { runCatching { repository.updatePrivateVisibility(value) } } + } + + fun changeGroupVisible(id: Int) { + groupVisibleId = id + viewModelScope.launch { runCatching { repository.updateGroupVisibleCalendar(id.takeIf { it != 0 }) } } + } +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt new file mode 100644 index 0000000..8e369a7 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt @@ -0,0 +1,13 @@ +package com.scarriffle.calendarr.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF4285F4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_background = Color(0xFFFFFFFF) +val md_theme_light_onBackground = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF8AB4F8) +val md_theme_dark_onPrimary = Color(0xFF000000) +val md_theme_dark_background = Color(0xFF000000) +val md_theme_dark_onBackground = Color(0xFFFFFFFF) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt new file mode 100644 index 0000000..f6e41f0 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt @@ -0,0 +1,74 @@ +package com.scarriffle.calendarr.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor + +/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's + * primary colour is unset. */ +val BrandGreen = Color(0xFF20A050) + +/** + * Fully dynamic theme: every colour is derived from [AppSettings] so the whole + * app follows the user's palette (matching the web client). primary/accent tint + * the controls; background/text/line drive `background`/`onBackground`/`outline`, + * which the calendar views already read (grid, secondary text). "today", divider + * and label colours are read directly in the views. + */ +@Composable +fun CalendarrTheme( + settings: AppSettings = AppSettings(), + content: @Composable () -> Unit, +) { + val primary = 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 colors = darkColorScheme( + primary = primary, + onPrimary = primary.contrastingTextColor(), + primaryContainer = container, + onPrimaryContainer = onContainer, + secondary = accent, + onSecondary = accent.contrastingTextColor(), + secondaryContainer = container, + onSecondaryContainer = onContainer, + tertiary = accent, + onTertiary = accent.contrastingTextColor(), + tertiaryContainer = container, + onTertiaryContainer = onContainer, + surfaceTint = primary, + background = bg, + onBackground = onBg, + surface = surface, + onSurface = onBg, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onBg.copy(alpha = 0.7f), + outline = line, + ) + + MaterialTheme( + colorScheme = colors, + typography = Typography(), + // extraSmall drives DropdownMenu containers (default 4dp looks boxy); + // rounder popups to match the iOS look. + shapes = Shapes(extraSmall = RoundedCornerShape(14.dp)), + content = content, + ) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt b/android/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt new file mode 100644 index 0000000..1046116 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt @@ -0,0 +1,51 @@ +package com.scarriffle.calendarr.util + +import androidx.compose.ui.graphics.Color + +/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */ +fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color { + if (hex.isNullOrBlank()) return fallback + val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() } + return when (clean.length) { + 3 -> runCatching { + // #RGB shorthand -> expand each nibble + val r = clean[0].digitToInt(16) * 17 + val g = clean[1].digitToInt(16) * 17 + val b = clean[2].digitToInt(16) * 17 + Color(r / 255f, g / 255f, b / 255f, 1f) + }.getOrDefault(fallback) + 6 -> runCatching { + val v = clean.toLong(16) + Color( + red = ((v shr 16) and 0xFF) / 255f, + green = ((v shr 8) and 0xFF) / 255f, + blue = (v and 0xFF) / 255f, + alpha = 1f, + ) + }.getOrDefault(fallback) + 8 -> runCatching { + val v = clean.toLong(16) + Color( + alpha = ((v shr 24) and 0xFF) / 255f, + red = ((v shr 16) and 0xFF) / 255f, + green = ((v shr 8) and 0xFF) / 255f, + blue = (v and 0xFF) / 255f, + ) + }.getOrDefault(fallback) + else -> fallback + } +} + +/** Convert a Compose [Color] back to a "#RRGGBB" string. */ +fun Color.toHex(): String { + val r = (red * 255).toInt().coerceIn(0, 255) + val g = (green * 255).toInt().coerceIn(0, 255) + val b = (blue * 255).toInt().coerceIn(0, 255) + return String.format("#%02X%02X%02X", r, g, b) +} + +/** Pick black or white text for legibility on a given background color. */ +fun Color.contrastingTextColor(): Color { + val luminance = 0.299 * red + 0.587 * green + 0.114 * blue + return if (luminance > 0.6) Color.Black else Color.White +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt b/android/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt new file mode 100644 index 0000000..1f42f10 --- /dev/null +++ b/android/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt @@ -0,0 +1,77 @@ +package com.scarriffle.calendarr.util + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * Date parsing/formatting that mirrors the iOS client (CalEvent.swift). + * + * The backend may produce any of these forms: + * "2026-05-17" (all-day, date only) + * "2026-05-17T10:00:00Z" + * "2026-05-17T10:00:00+02:00" + * "2026-05-17T10:00:00.000Z" + * "2026-05-17T10:00:00" (no timezone -> treat as UTC) + * "2026-05-17 10:00:00+00:00" (Python isoformat, space separator) + */ +object Dates { + + private val zone: ZoneId get() = ZoneId.systemDefault() + + private val spaceSep: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXXX") + private val noTz: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + + /** Parse a server date string into an [Instant]. Returns null if unparseable. */ + fun parse(raw: String, allDay: Boolean): Instant? { + val s = raw.trim() + if (s.isEmpty()) return null + + if (allDay || (s.length == 10 && !s.contains('T'))) { + return runCatching { + LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant() + }.getOrNull() + } + // ISO with offset / Z + runCatching { return OffsetDateTime.parse(s).toInstant() } + runCatching { return Instant.parse(s) } + // Python isoformat with a space + runCatching { return OffsetDateTime.parse(s, spaceSep).toInstant() } + // No timezone -> UTC + runCatching { + return LocalDateTime.parse(s.take(19), noTz).toInstant(ZoneOffset.UTC) + } + // Last resort: just the date part + return runCatching { + LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant() + }.getOrNull() + } + + /** Format an [Instant] for sending to the server. */ + fun format(instant: Instant, allDay: Boolean): String { + return if (allDay) { + LocalDate.ofInstant(instant, zone).toString() // yyyy-MM-dd + } else { + // ISO8601 with Z, no fractional seconds (matches iOS isoBasic) + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + .format(instant) + } + } + + /** ISO8601 UTC string used for the events query range (`?start=&end=`). */ + fun isoUtc(instant: Instant): String = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + .format(instant) + + fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) + + fun startOfDay(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant() +} diff --git a/android/app/src/main/res/drawable-nodpi/ic_splash_logo.png b/android/app/src/main/res/drawable-nodpi/ic_splash_logo.png new file mode 100644 index 0000000..a88fa5b Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/ic_splash_logo.png differ diff --git a/android/app/src/main/res/drawable/splash_icon.xml b/android/app/src/main/res/drawable/splash_icon.xml new file mode 100644 index 0000000..0073872 --- /dev/null +++ b/android/app/src/main/res/drawable/splash_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/drawable/splash_transparent.xml b/android/app/src/main/res/drawable/splash_transparent.xml new file mode 100644 index 0000000..73ad064 --- /dev/null +++ b/android/app/src/main/res/drawable/splash_transparent.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..59317c1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3754c90 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..59317c1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c84e2e5 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d398e54 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..c84e2e5 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7875186 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c7ec232 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7875186 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..a99ac08 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..bff5503 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a99ac08 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..45aa7c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..61733e3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..45aa7c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-v31/themes.xml b/android/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..a5de45d --- /dev/null +++ b/android/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,8 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..507019d --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Calendarr + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..180863e --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + #000000 + #16703D + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..6df8e44 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1 @@ +// Gradle wrapper will be generated once settings and build files are present. diff --git a/android/claude.md b/android/claude.md new file mode 100644 index 0000000..3e5f040 --- /dev/null +++ b/android/claude.md @@ -0,0 +1,49 @@ +# Calendarr Android + +## Projektübersicht +Android-App für den Calendarr-Server. Funktionsgleich mit der iOS-App (Swift, liegt unter ../Calendarr iOS/). +Der Server-Code liegt unter ../Calendarr server/. + +## ⚠️ WICHTIG: Read-Only Ordner +Die folgenden Ordner dienen NUR zur Referenz. NIEMALS Dateien darin verändern, löschen oder erstellen: +- `../Calendarr iOS/` → READ ONLY – nur zur Analyse der Features und API-Nutzung +- `../Calendarr server/` → READ ONLY – nur zur Analyse der API-Endpunkte und Datenmodelle + +Alle Änderungen und neue Dateien kommen ausschliesslich in `../Calendarr Android/`. + +## Server +- Basis-URL (Prod): https://calendar.scarriffle.com +- Auth: Erst Server-URL eingeben, dann Benutzername + Passwort +- Credentials werden auf Android im EncryptedSharedPreferences gespeichert (Äquivalent zu Apple Keychain) + +## Tech Stack +- Sprache: Kotlin +- UI: Jetpack Compose + Material 3 +- Architektur: MVVM +- Netzwerk: Retrofit + OkHttp +- Lokale Speicherung: EncryptedSharedPreferences (Credentials), Room (Cache) +- Async: Coroutines + Flow +- DI: Hilt + +## Projektstruktur +- `../Calendarr iOS/` → READ ONLY – Swift-App als Referenz +- `../Calendarr server/` → READ ONLY – Server als Referenz +- `../Calendarr Android/` → dieses Projekt, hier wird gebaut + +## Git +- Remote: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git +- Nach jeder abgeschlossenen Funktionseinheit committen und pushen +- Commit-Messages auf Englisch, format: `feat: ...` / `fix: ...` / `chore: ...` +- Niemals halbfertigen oder nicht-kompilierenden Code pushen + +## Build +- Debug APK: `./gradlew assembleDebug` +- Release APK: `./gradlew assembleRelease` +- Tests: `./gradlew test` + +## Konventionen +- Kein XML für Layouts – ausschliesslich Jetpack Compose +- ViewModels dürfen keine Android-Framework-Klassen direkt referenzieren +- StateFlow für UI-State, keine LiveData +- Alle Strings in res/values/strings.xml +- Vor jedem Push: `./gradlew build` muss fehlerfrei durchlaufen diff --git a/android/fastlane/metadata/android/de-DE/changelogs/2.txt b/android/fastlane/metadata/android/de-DE/changelogs/2.txt new file mode 100644 index 0000000..4af155c --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/changelogs/2.txt @@ -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 diff --git a/android/fastlane/metadata/android/de-DE/full_description.txt b/android/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 0000000..f792dba --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/full_description.txt @@ -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. diff --git a/android/fastlane/metadata/android/de-DE/images/icon.png b/android/fastlane/metadata/android/de-DE/images/icon.png new file mode 100644 index 0000000..49dd9b5 Binary files /dev/null and b/android/fastlane/metadata/android/de-DE/images/icon.png differ diff --git a/android/fastlane/metadata/android/de-DE/short_description.txt b/android/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 0000000..f3494e8 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +Kalender-App für deinen selbst gehosteten Calendarr-Server diff --git a/android/fastlane/metadata/android/de-DE/title.txt b/android/fastlane/metadata/android/de-DE/title.txt new file mode 100644 index 0000000..a120c46 --- /dev/null +++ b/android/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +Calendarr diff --git a/android/fastlane/metadata/android/en-US/changelogs/2.txt b/android/fastlane/metadata/android/en-US/changelogs/2.txt new file mode 100644 index 0000000..fc651e4 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/2.txt @@ -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 diff --git a/android/fastlane/metadata/android/en-US/full_description.txt b/android/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..91dd547 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/full_description.txt @@ -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. diff --git a/android/fastlane/metadata/android/en-US/images/icon.png b/android/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..49dd9b5 Binary files /dev/null and b/android/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/android/fastlane/metadata/android/en-US/short_description.txt b/android/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..57c4e7c --- /dev/null +++ b/android/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Calendar client for your self-hosted Calendarr server diff --git a/android/fastlane/metadata/android/en-US/title.txt b/android/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..a120c46 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Calendarr diff --git a/android/fdroid/com.scarriffle.calendarr.yml b/android/fdroid/com.scarriffle.calendarr.yml new file mode 100644 index 0000000..e046785 --- /dev/null +++ b/android/fdroid/com.scarriffle.calendarr.yml @@ -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` (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 diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..7096c03 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,33 @@ +org.gradle.jvmargs=-Xmx2g \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED +android.useAndroidX=true +android.enableJetifier=false +kotlin.code.style=official + +# kapt (Kotlin 1.9.x) needs access to internal javac APIs on JDK 16+. +# Without these exports the Kotlin daemon throws IllegalAccessError when +# generating annotation-processor stubs (Hilt). Applied to the Kotlin daemon. +kotlin.daemon.jvmargs=-Xmx2g \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4f5eb9d --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/keystore.properties.example b/android/keystore.properties.example new file mode 100644 index 0000000..7f7a5a0 --- /dev/null +++ b/android/keystore.properties.example @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a99c318 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "CalendarrAndroid" +include(":app")