From 75f5e6024b67a59be7b15ab5f511c6577e4d962c Mon Sep 17 00:00:00 2001 From: RomanNum3ral Date: Thu, 16 Jul 2026 18:19:03 -0400 Subject: [PATCH] Initial commit: NCal, a native Nextcloud CalDAV client for Android One app for both events and tasks, talking directly to Nextcloud's CalDAV endpoint - no DAVx5, no Google Play Services, no third-party services. Encrypted at rest (SQLCipher DB, EncryptedSharedPreferences, Keystore-backed widget state), HTTPS-only networking, home screen widgets, reminders, subtasks, and full VTODO field support (status/priority/%complete/location/url). --- .gitignore | 6 + LICENSE | 674 ++++++++++++++++++ README.md | 171 +++++ ncal/.gitignore | 15 + ncal/app/build.gradle.kts | 110 +++ ncal/app/proguard-rules.pro | 3 + ncal/app/src/main/AndroidManifest.xml | 69 ++ .../java/com/homelab/ncal/MainActivity.kt | 66 ++ .../java/com/homelab/ncal/NcalApplication.kt | 53 ++ .../com/homelab/ncal/data/db/AppDatabase.kt | 34 + .../homelab/ncal/data/db/CollectionEntity.kt | 37 + .../com/homelab/ncal/data/db/ItemEntity.kt | 62 ++ .../ncal/data/model/CalendarCollection.kt | 18 + .../homelab/ncal/data/model/CalendarItem.kt | 47 ++ .../homelab/ncal/data/network/CalDavClient.kt | 209 ++++++ .../com/homelab/ncal/data/network/DavXml.kt | 144 ++++ .../homelab/ncal/data/prefs/SecurePrefs.kt | 84 +++ .../data/repository/NextcloudRepository.kt | 214 ++++++ .../ncal/notifications/BootReceiver.kt | 26 + .../ncal/notifications/ReminderReceiver.kt | 85 +++ .../ncal/notifications/ReminderScheduler.kt | 63 ++ .../com/homelab/ncal/ui/ViewModelFactory.kt | 24 + .../homelab/ncal/ui/agenda/AgendaScreen.kt | 169 +++++ .../homelab/ncal/ui/agenda/AgendaViewModel.kt | 73 ++ .../ncal/ui/collections/CollectionsScreen.kt | 89 +++ .../ui/collections/CollectionsViewModel.kt | 62 ++ .../homelab/ncal/ui/detail/ItemEditScreen.kt | 538 ++++++++++++++ .../ncal/ui/detail/ItemEditViewModel.kt | 143 ++++ .../com/homelab/ncal/ui/login/LoginScreen.kt | 111 +++ .../homelab/ncal/ui/login/LoginViewModel.kt | 61 ++ .../com/homelab/ncal/ui/month/MonthScreen.kt | 374 ++++++++++ .../homelab/ncal/ui/month/MonthViewModel.kt | 83 +++ .../com/homelab/ncal/ui/nav/NcalNavGraph.kt | 124 ++++ .../com/homelab/ncal/ui/tasks/TasksScreen.kt | 108 +++ .../homelab/ncal/ui/tasks/TasksViewModel.kt | 89 +++ .../java/com/homelab/ncal/ui/theme/Theme.kt | 38 + .../com/homelab/ncal/util/DateRangeUtils.kt | 37 + .../com/homelab/ncal/util/ErrorMessages.kt | 20 + .../java/com/homelab/ncal/util/IcsMapper.kt | 193 +++++ .../com/homelab/ncal/util/MonthGridLayout.kt | 68 ++ .../com/homelab/ncal/util/PriorityColors.kt | 21 + .../com/homelab/ncal/util/RecurrenceUtils.kt | 67 ++ .../ncal/widget/EncryptedWidgetState.kt | 79 ++ .../homelab/ncal/widget/MonthGridWidget.kt | 324 +++++++++ .../ncal/widget/MonthGridWidgetReceiver.kt | 8 + .../homelab/ncal/widget/MonthWidgetActions.kt | 22 + .../homelab/ncal/widget/NextTasksWidget.kt | 165 +++++ .../ncal/widget/NextTasksWidgetActions.kt | 18 + .../ncal/widget/NextTasksWidgetReceiver.kt | 8 + .../res/drawable/ic_launcher_foreground.xml | 12 + .../layout/glance_default_loading_layout.xml | 11 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + ncal/app/src/main/res/values/colors.xml | 3 + ncal/app/src/main/res/values/strings.xml | 7 + ncal/app/src/main/res/values/themes.xml | 3 + .../main/res/xml/month_grid_widget_info.xml | 11 + .../main/res/xml/next_tasks_widget_info.xml | 11 + ncal/build.gradle.kts | 6 + ncal/gradle.properties | 4 + ncal/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes ncal/gradle/wrapper/gradle-wrapper.properties | 7 + ncal/gradlew | 249 +++++++ ncal/gradlew.bat | 92 +++ ncal/settings.gradle.kts | 17 + 64 files changed, 5744 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ncal/.gitignore create mode 100644 ncal/app/build.gradle.kts create mode 100644 ncal/app/proguard-rules.pro create mode 100644 ncal/app/src/main/AndroidManifest.xml create mode 100644 ncal/app/src/main/java/com/homelab/ncal/MainActivity.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/db/CollectionEntity.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarCollection.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/prefs/SecurePrefs.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/notifications/BootReceiver.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderReceiver.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderScheduler.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/ViewModelFactory.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/theme/Theme.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/util/DateRangeUtils.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/util/MonthGridLayout.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/util/PriorityColors.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/EncryptedWidgetState.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidget.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidgetReceiver.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/MonthWidgetActions.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetActions.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetReceiver.kt create mode 100644 ncal/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 ncal/app/src/main/res/layout/glance_default_loading_layout.xml create mode 100644 ncal/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 ncal/app/src/main/res/values/colors.xml create mode 100644 ncal/app/src/main/res/values/strings.xml create mode 100644 ncal/app/src/main/res/values/themes.xml create mode 100644 ncal/app/src/main/res/xml/month_grid_widget_info.xml create mode 100644 ncal/app/src/main/res/xml/next_tasks_widget_info.xml create mode 100644 ncal/build.gradle.kts create mode 100644 ncal/gradle.properties create mode 100644 ncal/gradle/wrapper/gradle-wrapper.jar create mode 100644 ncal/gradle/wrapper/gradle-wrapper.properties create mode 100755 ncal/gradlew create mode 100644 ncal/gradlew.bat create mode 100644 ncal/settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aeacf3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Claude Code session-local config - not part of the project +.claude/ + +# Release signing - never commit these (see ncal/.gitignore for the enforced copy) +/ncal/keystore/ +/ncal/keystore.properties diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..2898e21 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# NCal + +A native Android app that talks **directly to Nextcloud's CalDAV endpoint** — no +DAVx⁵, no separate tasks app, no Android Calendar/Tasks Provider in the middle. One +app, full CRUD, for both events and tasks, plus subtasks, reminders, and home screen +widgets — built to run on phones with **no Google Play Services at all**. + +The Android project lives in [`ncal/`](ncal/); open that folder in Android Studio. + +## Why this exists + +No mainstream Android app natively renders both `VEVENT` (events) and `VTODO` (tasks) +from CalDAV in one UI. DAVx⁵ + Etar + Tasks.org gets you three apps for one job. This +is the fourth option: one app, one account, one sync engine. + +## Privacy & security + +- **No Google Play Services, anywhere.** Sync, reminders, and the home screen widgets + all use plain AOSP/AndroidX APIs (`AlarmManager`, `NotificationManager`, + `androidx.glance`, `androidx.work`) — every one of these is a core platform + capability, not a Google service, so the app runs unmodified on GrapheneOS, + LineageOS, or any other de-googled ROM. +- **No analytics, crash reporting, or ad SDKs.** Check `ncal/app/build.gradle.kts` — + every dependency is either AndroidX/Jetpack, OkHttp, or `biweekly` (iCalendar + parsing). Nothing phones home to a vendor. +- **No third-party server.** The only network calls in the app go to whatever + Nextcloud server URL *you* enter at login (`data/network/CalDavClient.kt`). There is + no hardcoded third-party endpoint anywhere in the source. +- **Everything is encrypted at rest.** Server URL, username, and app password live in + `EncryptedSharedPreferences` (`data/prefs/SecurePrefs.kt`, AES-256-GCM/SIV via an + Android Keystore-backed `MasterKey`). The local Room cache itself is an + SQLCipher-encrypted database (`ncal_encrypted.db`), not plain SQLite — nothing the + app writes to disk, including the widgets' own tiny bit of navigation state, is ever + unencrypted. `android:allowBackup="false"` also keeps Android's cloud backup from + ever exporting any of it off-device. +- **HTTPS only.** `usesCleartextTraffic="false"` blocks plain HTTP at the OS network + layer, and the login screen always normalizes whatever you type to `https://`. +- **Local-first.** The calendar/task cache is a local, encrypted database. It exists + purely so the UI renders instantly and works offline; the server is always the + source of truth on next sync. + +## Features + +- **Agenda** — unified, day-grouped list of events *and* tasks. Syncs automatically + every time the app is opened, plus pull to refresh any time. +- **Month view** — a full calendar grid with events rendered as spanning colored + pills and tasks as dot + text, exactly mirroring what's on the home screen widget + (they share the same layout algorithm). Tap a day to see its agenda below the grid. +- **Tasks** — dedicated checklist view. Tasks render as an indented tree under their + parent, not a flat list. +- **Subtasks** — assign any task a parent task (stored as the standard iCalendar + `RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other + CalDAV clients). The parent picker excludes the task itself and its own descendants + to prevent cycles. +- **Reminders** — add one or more reminders to any event or task (at the scheduled + time, or a preset offset before it: 5 min up to 1 week). Reminders fire as real + system notifications via `AlarmManager` exact alarms — not a best-effort background + job — and survive reboots and app updates. Tapping a reminder notification opens + the app directly to that item. +- **Task status, priority, % complete, location, and URL** — full VTODO fields, not + just a completed checkbox: a proper status (Not started / In progress / Completed / + Cancelled), an iCal `PRIORITY` 1-9 picker color-coded 1-4 red/5 yellow/6-9 blue + (surfaced as the task's leading dot everywhere it's shown — Tasks list, Month view, + both widgets), a 0-100% completion slider, and location/URL fields shared with + events. +- **Home screen widgets** (see below). +- **Calendars** — pick which calendars/task-lists sync, mirroring what you'd see in + Nextcloud's own sidebar. +- **Recurring events and tasks** — `RRULE` (`FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with + `INTERVAL`/`COUNT`/`UNTIL`) is evaluated client-side to re-date each item to its + next upcoming occurrence. See "Known limitations" below for what this doesn't cover. + +## Home screen widgets + +Two widgets, built with Jetpack Glance (`androidx.glance:glance-appwidget`), which +compiles down to plain `RemoteViews`/`AppWidgetProvider` — no Google dependency: + +- **Month grid widget** ("Calendar Month" in the widget picker) — the exact same + month view as the in-app Month screen (shared layout code in + `util/MonthGridLayout.kt`), with ‹ › month navigation and tap-to-open on any event + or task. Resizable, and fonts/day-circle size/event-lane count all scale up when + you make the widget bigger instead of leaving blank space. +- **Next Tasks widget** ("Tasks" in the widget picker) — a scrollable list of + incomplete tasks grouped and ordered by due date, with a tap-to-complete circle on + each row (color-coded by priority) and tap-to-open on the row itself. + +Both widgets refresh automatically whenever you sync, save, or delete something in +the app — no manual refresh needed. Note this means a widget's content is only as +fresh as the last time the app itself synced (on open, on manual pull-to-refresh, or +after an edit) — the widgets read from the local cache on their own ~30-minute OS +redraw timer, they don't independently hit the network. + +## Architecture + +- **Kotlin + Jetpack Compose (Material 3)**, MVVM, single-activity, no DI framework + (`ui/ViewModelFactory.kt` is a small hand-rolled factory instead). +- **`CalDavClient`** (`data/network/`) — hand-rolled CalDAV client over OkHttp. Speaks + `PROPFIND` (discover calendars/task-lists), `REPORT` (`calendar-query` to fetch + `VEVENT`/`VTODO`), and `PUT`/`DELETE` with `If-Match`/`If-None-Match` for safe, + conflict-aware writes. Every successful `PUT` captures the server's returned `ETag` + so the next edit doesn't race against a stale local copy. +- **`biweekly`** — parses/generates the actual iCalendar (RFC 5545) payloads, + including `VALARM` (reminders) and `RELATED-TO` (subtasks). +- **Room, on an SQLCipher-encrypted database** (`data/db/`) — local cache so every + screen (and both widgets) render instantly and survive being offline, without ever + touching disk unencrypted. +- **`NextcloudRepository`** — single source of truth gluing the network client, the + DB, reminder scheduling, and widget refresh together. Every screen reads from Room + `Flow`s; every write goes through `save()`/`delete()`, which do the network call + first, then update the cache, reminders, and widgets. +- **`ReminderScheduler`** (`notifications/`) — schedules one exact `AlarmManager` + alarm per item, always for its *next* upcoming reminder offset; the receiver + re-arms the following offset each time it fires, so there's never more than one + pending alarm per item to track. +- **`MonthGridWidget` / `NextTasksWidget`** (`widget/`) — Glance widgets that read + straight from the same repository the app uses. + +## Setup + +1. **Create a Nextcloud app password** — not your real login password. + Settings → Security → Devices & sessions → Create new app password. +2. **Open `ncal/` in Android Studio** (Koala/2024.1 or newer). Let it sync Gradle. +3. Run on a device or emulator (min SDK 26 / Android 8+). +4. On first launch, enter your server URL, username, and the app password. +5. Go to the **Calendars** screen (gear icon) and confirm which calendars/task-lists + are enabled. +6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month" + and/or "Tasks". + +## Known limitations + +- **Editing an existing recurring item drops its `RRULE`.** Saving rebuilds the + `.ics` from the edit form's fields, so an edited recurring item becomes a one-off. + Viewing/completing/deleting a recurring item is safe; editing its fields is not, + if you want the series to survive the save. `CalendarItem.rawIcs` retains the + original document for exactly this reason — wiring `toIcs()` to mutate the parsed + original in place is the natural fix if this turns out to matter in practice. +- **No recurrence-editing UI** — no picker to *set* a recurrence rule on a new item. +- **Subtask tree is app-side only.** The Month view and both widgets show tasks + flat/chronological, not nested — only the in-app Tasks screen renders the parent/ + child tree. +- **No background sync while the app is closed.** Sync happens on app open, manual + pull-to-refresh, or after an edit — there's no periodic `WorkManager` job calling + `repository.fullSync()` yet, so a widget can go stale if you never open the app. +- **Last-write-wins conflict handling.** A genuinely stale write fails loudly (a + `CalDavException` with the HTTP code) rather than silently clobbering a change made + elsewhere — but there's no merge UI, just an error surfaced to the edit screen. + +## Where to look first if you want to extend it + +- `data/network/CalDavClient.kt` — all server communication. +- `util/IcsMapper.kt` — the iCalendar ⇄ app-model translation layer (events, tasks, + subtasks, reminders). +- `util/MonthGridLayout.kt` — the month-grid layout algorithm shared by the in-app + Month screen and the month widget. +- `data/repository/NextcloudRepository.kt` — the sync/cache/reminder/widget + orchestration. +- `notifications/ReminderScheduler.kt` — reminder alarm scheduling. +- `widget/` — the two Glance home screen widgets. + +## Building a signed release + +`ncal/app/build.gradle.kts` reads signing credentials from a git-ignored +`ncal/keystore.properties` (`storePassword`, `keyPassword`, `keyAlias`, `storeFile`) — +generate your own keystore and fill that file in; nothing under `ncal/keystore/` or +`ncal/keystore.properties` is tracked by git, and no signing key ships in this repo. +Without that file present, `:app:assembleRelease` still produces an **unsigned** APK. + +## License + +GPL-3.0 — see [`LICENSE`](LICENSE). diff --git a/ncal/.gitignore b/ncal/.gitignore new file mode 100644 index 0000000..1506309 --- /dev/null +++ b/ncal/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle/ +/local.properties +.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build + +# Release signing - never commit these +/keystore/ +/keystore.properties diff --git a/ncal/app/build.gradle.kts b/ncal/app/build.gradle.kts new file mode 100644 index 0000000..64a7602 --- /dev/null +++ b/ncal/app/build.gradle.kts @@ -0,0 +1,110 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "com.homelab.ncal" + compileSdk = 34 + + defaultConfig { + applicationId = "com.homelab.ncal" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + storeFile = rootProject.file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3") + implementation("androidx.activity:activity-compose:1.9.0") + + // Compose + implementation(platform("androidx.compose:compose-bom:2024.09.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.7") + + // Room + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") + + // Networking (CalDAV/WebDAV over HTTP) + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // iCalendar (RFC 5545) parsing/generation for VEVENT/VTODO + implementation("net.sf.biweekly:biweekly:0.6.8") + + // Encrypted storage for server credentials + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + // WorkManager - required transitively by Glance's own widget-scheduling machinery; + // declared explicitly to pin a current version rather than Glance's older transitive default. + implementation("androidx.work:work-runtime-ktx:2.9.0") + + // Home screen widget (month calendar) + implementation("androidx.glance:glance-appwidget:1.1.1") + + // Encrypts the local Room cache at rest (calendar/task content), same as credentials already are + implementation("net.zetetic:android-database-sqlcipher:4.5.4") +} diff --git a/ncal/app/proguard-rules.pro b/ncal/app/proguard-rules.pro new file mode 100644 index 0000000..f8a25d9 --- /dev/null +++ b/ncal/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Add project specific ProGuard rules here. +-keep class biweekly.** { *; } +-dontwarn biweekly.** diff --git a/ncal/app/src/main/AndroidManifest.xml b/ncal/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b59e295 --- /dev/null +++ b/ncal/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ncal/app/src/main/java/com/homelab/ncal/MainActivity.kt b/ncal/app/src/main/java/com/homelab/ncal/MainActivity.kt new file mode 100644 index 0000000..1663cf6 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/MainActivity.kt @@ -0,0 +1,66 @@ +package com.homelab.ncal + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.homelab.ncal.ui.nav.NcalNavGraph +import com.homelab.ncal.ui.theme.NCalTheme + +class MainActivity : ComponentActivity() { + + private var pendingDeepLink by mutableStateOf?>(null) + + private val requestNotificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + pendingDeepLink = deepLinkFrom(intent) + + setContent { + NCalTheme { + Surface(modifier = Modifier.fillMaxSize()) { + NcalNavGraph( + app = application as NcalApplication, + pendingDeepLink = pendingDeepLink, + onDeepLinkConsumed = { pendingDeepLink = null } + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + pendingDeepLink = deepLinkFrom(intent) + } + + private fun deepLinkFrom(intent: Intent?): Pair? { + val type = intent?.getStringExtra(EXTRA_ITEM_TYPE) ?: return null + val href = intent.getStringExtra(EXTRA_ITEM_HREF) ?: return null + return type to href + } + + companion object { + const val EXTRA_ITEM_TYPE = "item_type" + const val EXTRA_ITEM_HREF = "item_href" + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt new file mode 100644 index 0000000..8a93d34 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt @@ -0,0 +1,53 @@ +package com.homelab.ncal + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import androidx.glance.appwidget.updateAll +import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.notifications.CHANNEL_ID +import com.homelab.ncal.notifications.ReminderScheduler +import com.homelab.ncal.widget.MonthGridWidget +import com.homelab.ncal.widget.NextTasksWidget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import net.sqlcipher.database.SQLiteDatabase + +class NcalApplication : Application() { + lateinit var repository: NextcloudRepository + private set + + override fun onCreate() { + super.onCreate() + SQLiteDatabase.loadLibs(this) + deleteObsoletePlaintextDatabase() + repository = NextcloudRepository(this) + createReminderNotificationChannel() + + if (repository.isLoggedIn) { + CoroutineScope(Dispatchers.IO).launch { + ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems()) + MonthGridWidget().updateAll(this@NcalApplication) + NextTasksWidget().updateAll(this@NcalApplication) + } + } + } + + /** The DB moved to an encrypted file (`ncal_encrypted.db`) - the old plaintext copy would + * otherwise sit on disk unencrypted and unused, which defeats the point of switching. */ + private fun deleteObsoletePlaintextDatabase() { + deleteDatabase("ncal.db") + } + + private fun createReminderNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + "Reminders", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Event and task reminders" + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt new file mode 100644 index 0000000..0e5da35 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt @@ -0,0 +1,34 @@ +package com.homelab.ncal.data.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import net.sqlcipher.database.SupportFactory + +@Database( + entities = [CollectionEntity::class, ItemEntity::class], + version = 4, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun collectionDao(): CollectionDao + abstract fun itemDao(): ItemDao + + companion object { + @Volatile private var instance: AppDatabase? = null + + /** [passphrase] encrypts the database file at rest via SQLCipher - see [com.homelab.ncal.data.prefs.SecurePrefs.databaseKey]. */ + fun get(context: Context, passphrase: ByteArray): AppDatabase = + instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "ncal_encrypted.db" + ) + .openHelperFactory(SupportFactory(passphrase)) + .fallbackToDestructiveMigration() + .build().also { instance = it } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/CollectionEntity.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/CollectionEntity.kt new file mode 100644 index 0000000..af9a758 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/CollectionEntity.kt @@ -0,0 +1,37 @@ +package com.homelab.ncal.data.db + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "collections") +data class CollectionEntity( + @PrimaryKey val url: String, + val displayName: String, + val colorHex: String?, + val supportsEvents: Boolean, + val supportsTasks: Boolean, + val ctag: String?, + val enabled: Boolean = true +) + +@Dao +interface CollectionDao { + @Query("SELECT * FROM collections ORDER BY displayName") + fun observeAll(): Flow> + + @Query("SELECT * FROM collections") + suspend fun getAll(): List + + @Upsert + suspend fun upsertAll(collections: List) + + @Query("UPDATE collections SET enabled = :enabled WHERE url = :url") + suspend fun setEnabled(url: String, enabled: Boolean) + + @Query("DELETE FROM collections WHERE url NOT IN (:keepUrls)") + suspend fun pruneMissing(keepUrls: List) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt new file mode 100644 index 0000000..9a73ca7 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt @@ -0,0 +1,62 @@ +package com.homelab.ncal.data.db + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "items") +data class ItemEntity( + @PrimaryKey val href: String, + val uid: String, + val etag: String?, + val calendarUrl: String, + val type: String, // "EVENT" | "TASK" + val summary: String, + val description: String, + val location: String, + val url: String, + val start: Long?, + val end: Long?, + val allDay: Boolean, + val due: Long?, + val completed: Boolean, + val status: String, // TaskStatus name, tasks only + val percentComplete: Int, + val priority: Int, + val parentUid: String?, + val reminderMinutes: String, // comma-separated minutes-before list, "" if none + val rawIcs: String? +) + +@Dao +interface ItemDao { + @Query("SELECT * FROM items WHERE calendarUrl IN (:calendarUrls) ORDER BY COALESCE(start, due) ASC") + fun observeForCalendars(calendarUrls: List): Flow> + + @Query("SELECT * FROM items WHERE type = 'TASK' AND calendarUrl IN (:calendarUrls) ORDER BY completed ASC, COALESCE(due, 9223372036854775807) ASC") + fun observeTasks(calendarUrls: List): Flow> + + @Query("SELECT * FROM items WHERE type = 'EVENT' AND calendarUrl IN (:calendarUrls) ORDER BY start ASC") + fun observeEvents(calendarUrls: List): Flow> + + @Query("SELECT * FROM items WHERE href = :href LIMIT 1") + suspend fun getByHref(href: String): ItemEntity? + + @Query("SELECT * FROM items") + suspend fun getAllOnce(): List + + @Upsert + suspend fun upsertAll(items: List) + + @Upsert + suspend fun upsert(item: ItemEntity) + + @Query("DELETE FROM items WHERE href = :href") + suspend fun deleteByHref(href: String) + + @Query("DELETE FROM items WHERE calendarUrl = :calendarUrl AND href NOT IN (:keepHrefs)") + suspend fun pruneMissing(calendarUrl: String, keepHrefs: List) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarCollection.kt b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarCollection.kt new file mode 100644 index 0000000..f971e71 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarCollection.kt @@ -0,0 +1,18 @@ +package com.homelab.ncal.data.model + +/** + * A single CalDAV collection on the server, e.g. + * https://nextcloud.example.com/remote.php/dav/calendars/anon/personal/ + * + * A collection can support VEVENT (calendar), VTODO (task list), or both, + * depending on how it was created ("New calendar" vs "New calendar with task list"). + */ +data class CalendarCollection( + val url: String, + val displayName: String, + val colorHex: String?, + val supportsEvents: Boolean, + val supportsTasks: Boolean, + val ctag: String?, + val enabled: Boolean = true +) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt new file mode 100644 index 0000000..be09071 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt @@ -0,0 +1,47 @@ +package com.homelab.ncal.data.model + +enum class ItemType { EVENT, TASK } + +/** Mirrors the iCalendar VTODO STATUS values (tasks only). */ +enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } + +/** + * A single VEVENT or VTODO. Times are stored as epoch millis (UTC). + * [rawIcs] retains the full round-tripped iCalendar object so fields we don't + * explicitly model (RRULE, alarms, attendees, etc.) survive an edit-and-save cycle. + */ +data class CalendarItem( + val uid: String, + val href: String, // full resource URL, empty string for a not-yet-created item + val etag: String?, // null for a not-yet-created item + val calendarUrl: String, // which collection this belongs / will belong to + val type: ItemType, + + val summary: String, + val description: String = "", + val location: String = "", + val url: String = "", + + val start: Long? = null, + val end: Long? = null, + val allDay: Boolean = false, + + val due: Long? = null, // tasks only + val completed: Boolean = false, // tasks only - kept in sync with [status] == COMPLETED + val status: TaskStatus = TaskStatus.NEEDS_ACTION, // tasks only + val percentComplete: Int = 0, // tasks only, 0-100 + val priority: Int = 0, // 0 = undefined, 1 (high) .. 9 (low), iCal convention + val parentUid: String? = null, // tasks only: UID of the parent task (iCal RELATED-TO) + val reminderMinutes: List = emptyList(), // minutes before start (events) / due (tasks) to notify + + val rawIcs: String? = null +) { + val isNew: Boolean get() = href.isEmpty() + + /** Sets task completion consistently across [status]/[completed]/[percentComplete] so they never drift. */ + fun withStatus(newStatus: TaskStatus): CalendarItem = copy( + status = newStatus, + completed = newStatus == TaskStatus.COMPLETED, + percentComplete = if (newStatus == TaskStatus.COMPLETED) 100 else percentComplete + ) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt new file mode 100644 index 0000000..93f462e --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt @@ -0,0 +1,209 @@ +package com.homelab.ncal.data.network + +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit + +class CalDavException(message: String, val httpCode: Int? = null) : IOException(message) + +private val XML_MEDIA = "application/xml; charset=utf-8".toMediaType() +private val ICAL_MEDIA = "text/calendar; charset=utf-8".toMediaType() + +/** + * Speaks just enough CalDAV/WebDAV to drive NCal: discover calendars under a user's + * calendar-home, list/fetch VEVENT+VTODO objects, and create/update/delete them. + * + * This intentionally targets Nextcloud's DAV layout directly + * (https:///remote.php/dav/calendars//) rather than doing full + * RFC 4791 principal discovery, since that path is stable across Nextcloud versions. + */ +class CalDavClient( + private val serverUrl: String, + private val username: String, + private val appPassword: String +) { + private val http = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private val authHeader = Credentials.basic(username, appPassword) + + val calendarHomeUrl: String + get() = "${serverUrl.trimEnd('/')}/remote.php/dav/calendars/$username/" + + /** Verifies the credentials/URL by attempting to list the calendar home. */ + fun testConnection() { + discoverCollections() + } + + /** Lists all calendars/task-lists under the user's calendar home. */ + fun discoverCollections(): List { + val body = """ + + + + + + + + + """.trimIndent() + + val response = execute(calendarHomeUrl, "PROPFIND", body, mapOf("Depth" to "1")) + val all = DavXml.parseMultistatus(response) + + android.util.Log.d("NCalDiscovery", "PROPFIND to $calendarHomeUrl returned ${all.size} raw response(s):") + all.forEach { dav -> + android.util.Log.d( + "NCalDiscovery", + " href=${dav.href} displayName=${dav.displayName} resourceTypes=${dav.resourceTypes} supportedComponents=${dav.supportedComponents}" + ) + } + + // Drop the home collection itself and Nextcloud's internal inbox/outbox/trashbin. + // Prefer the 'calendar' resourcetype marker, but fall back to "has real + // supported-components" - some calendars (created outside Nextcloud's own "New + // calendar" UI, e.g. imported or created via a raw CalDAV client) come back + // without the resourcetype marker even though they're perfectly valid VEVENT/ + // VTODO collections. The system collections always report empty components, so + // this fallback doesn't risk pulling in anything that isn't a real calendar. + val filtered = all.filter { + (it.resourceTypes.contains("calendar") || it.supportedComponents.isNotEmpty()) && + !it.href.trimEnd('/').endsWith(username) + } + val dropped = all - filtered.toSet() + if (dropped.isNotEmpty()) { + android.util.Log.d("NCalDiscovery", "Filtered OUT ${dropped.size} response(s) (no 'calendar' resourcetype, or is the home collection itself):") + dropped.forEach { android.util.Log.d("NCalDiscovery", " dropped href=${it.href} resourceTypes=${it.resourceTypes}") } + } + return filtered + } + + /** Fetches raw iCalendar data for every VEVENT (or VTODO) object in a collection within [start, end]. */ + fun fetchItems(collectionUrl: String, component: String, start: java.util.Date, end: java.util.Date): List { + val body = """ + + + + + + + + + + + + + """.trimIndent() + + val response = execute(collectionUrl, "REPORT", body, mapOf("Depth" to "1")) + return DavXml.parseMultistatus(response).filter { it.calendarData != null } + } + + /** + * Fetches every object of [component] with no time bound. Used for VTODO: a time-range + * filter can exclude tasks that have neither DTSTART nor DUE set (RFC 4791 \u00a79.9), which + * would silently drop plain undated checklist-style tasks - common enough that it's not + * worth the tradeoff just to bound the query. + */ + fun fetchItems(collectionUrl: String, component: String): List { + val body = """ + + + + + + + + + + + """.trimIndent() + + val response = execute(collectionUrl, "REPORT", body, mapOf("Depth" to "1")) + return DavXml.parseMultistatus(response).filter { it.calendarData != null } + } + + private fun formatIcsUtc(date: java.util.Date): String { + val fmt = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", java.util.Locale.US) + fmt.timeZone = java.util.TimeZone.getTimeZone("UTC") + return fmt.format(date) + } + + /** Creates a new object. Returns the href it was created at. */ + /** Returns the href and the new etag the server assigned (if it returned one). */ + fun createItem(collectionUrl: String, ics: String, uid: String = UUID.randomUUID().toString()): Pair { + val href = "${collectionUrl.trimEnd('/')}/$uid.ics" + val request = Request.Builder() + .url(href) + .header("Authorization", authHeader) + .header("If-None-Match", "*") + .put(ics.toRequestBody(ICAL_MEDIA)) + .build() + val etag = executeRawEtag(request, expectBodyOnError = true) + return href to etag + } + + /** + * Updates an existing object. [etag] should be the last known etag for conflict safety. + * Returns the new etag the server assigned (if it returned one) - a PUT always changes the + * resource's etag server-side, so callers must persist this or every subsequent edit will + * 412 against the now-stale etag they still hold locally. + */ + fun updateItem(href: String, ics: String, etag: String?): String? { + val builder = Request.Builder() + .url(href) + .header("Authorization", authHeader) + .put(ics.toRequestBody(ICAL_MEDIA)) + if (!etag.isNullOrBlank()) builder.header("If-Match", etag) + return executeRawEtag(builder.build(), expectBodyOnError = true) + } + + fun deleteItem(href: String, etag: String?) { + val builder = Request.Builder() + .url(href) + .header("Authorization", authHeader) + .delete() + if (!etag.isNullOrBlank()) builder.header("If-Match", etag) + executeRaw(builder.build(), expectBodyOnError = true) + } + + private fun execute(url: String, method: String, xmlBody: String, headers: Map): String { + val builder = Request.Builder() + .url(url) + .header("Authorization", authHeader) + .header("Content-Type", "application/xml; charset=utf-8") + .method(method, xmlBody.toRequestBody(XML_MEDIA)) + headers.forEach { (k, v) -> builder.header(k, v) } + val request = builder.build() + return executeRaw(request, expectBodyOnError = true) ?: "" + } + + private fun executeRaw(request: Request, expectBodyOnError: Boolean): String? { + http.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + val detail = if (expectBodyOnError) resp.body?.string()?.take(300) else null + throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code) + } + return resp.body?.string() + } + } + + /** Like [executeRaw], but returns the response's ETag header instead of its body. */ + private fun executeRawEtag(request: Request, expectBodyOnError: Boolean): String? { + http.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + val detail = if (expectBodyOnError) resp.body?.string()?.take(300) else null + throw CalDavException("HTTP ${resp.code} for ${request.method} ${request.url}: ${detail ?: resp.message}", resp.code) + } + return resp.header("ETag") + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt new file mode 100644 index 0000000..cfaa35f --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/DavXml.kt @@ -0,0 +1,144 @@ +package com.homelab.ncal.data.network + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader + +/** One entry inside a WebDAV/CalDAV reply. */ +data class DavResponse( + val href: String, + val displayName: String? = null, + val resourceTypes: Set = emptySet(), + val supportedComponents: Set = emptySet(), + val ctag: String? = null, + val etag: String? = null, + val colorHex: String? = null, + val calendarData: String? = null +) + +/** + * Minimal streaming parser for WebDAV multistatus XML. We only pull the handful + * of properties NCal actually uses, ignoring namespaces beyond local-name matching + * (Nextcloud/sabre and other CalDAV servers are consistent enough about local names + * that this is reliable in practice, and it avoids pulling in a full XML DOM lib). + * + * IMPORTANT: a single can contain multiple blocks - one per + * distinct status code. A calendar that has, say, resourcetype/displayname but no + * custom calendar-color set will get its color property back in a *separate* + * with status 404, alongside a 200 for everything else the + * server does have. Status must be tracked per-propstat and merged only from the + * ones that succeeded - not as one flag for the whole - or perfectly + * valid calendars/task-lists silently vanish depending on which propstat happens + * to be parsed last. + */ +object DavXml { + + fun parseMultistatus(body: String): List { + val factory = XmlPullParserFactory.newInstance() + factory.isNamespaceAware = true + val parser = factory.newPullParser() + parser.setInput(StringReader(body)) + + val responses = mutableListOf() + + // Response-level accumulators - only ever filled from a *successful* propstat. + var href: String? = null + var displayName: String? = null + val resourceTypes = mutableSetOf() + val supportedComponents = mutableSetOf() + var ctag: String? = null + var etag: String? = null + var colorHex: String? = null + var calendarData: String? = null + + // Per-propstat scratch space, flushed into the accumulators above on + // only if that propstat's status was 2xx. + var inPropstat = false + var propOk = true + var propDisplayName: String? = null + val propResourceTypes = mutableSetOf() + val propSupportedComponents = mutableSetOf() + var propCtag: String? = null + var propEtag: String? = null + var propColorHex: String? = null + var propCalendarData: String? = null + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> { + when (parser.name) { + "response" -> { + href = null; displayName = null; ctag = null; etag = null + colorHex = null; calendarData = null + resourceTypes.clear(); supportedComponents.clear() + } + "propstat" -> { + inPropstat = true + propOk = true + propDisplayName = null; propCtag = null; propEtag = null + propColorHex = null; propCalendarData = null + propResourceTypes.clear(); propSupportedComponents.clear() + } + "href" -> href = parser.nextTextSafe() + "displayname" -> if (inPropstat) propDisplayName = parser.nextTextSafe() + "collection" -> if (inPropstat) propResourceTypes.add("collection") + "calendar" -> if (inPropstat) propResourceTypes.add("calendar") + "comp" -> if (inPropstat) parser.getAttributeValue(null, "name")?.let { propSupportedComponents.add(it) } + "getctag" -> if (inPropstat) propCtag = parser.nextTextSafe() + "getetag" -> if (inPropstat) propEtag = parser.nextTextSafe() + "calendar-color" -> if (inPropstat) propColorHex = parser.nextTextSafe()?.take(7) + "calendar-data" -> if (inPropstat) propCalendarData = parser.nextTextSafe() + "status" -> { + val status = parser.nextTextSafe() ?: "" + if (inPropstat) propOk = status.contains(" 2") // e.g. "HTTP/1.1 200 OK" + } + } + } + XmlPullParser.END_TAG -> { + when (parser.name) { + "propstat" -> { + if (propOk) { + propDisplayName?.let { displayName = it } + propCtag?.let { ctag = it } + propEtag?.let { etag = it } + propColorHex?.let { colorHex = it } + propCalendarData?.let { calendarData = it } + resourceTypes.addAll(propResourceTypes) + supportedComponents.addAll(propSupportedComponents) + } + inPropstat = false + } + "response" -> { + if (href != null) { + responses.add( + DavResponse( + href = href!!, + displayName = displayName, + resourceTypes = resourceTypes.toSet(), + supportedComponents = supportedComponents.toSet(), + ctag = ctag, + etag = etag, + colorHex = colorHex, + calendarData = calendarData + ) + ) + } + } + } + } + } + event = parser.next() + } + return responses + } + + /** Reads the text content of the current element without throwing on empty elements. */ + private fun XmlPullParser.nextTextSafe(): String? { + return try { + this.nextText()?.takeIf { it.isNotBlank() } + } catch (e: Exception) { + null + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/prefs/SecurePrefs.kt b/ncal/app/src/main/java/com/homelab/ncal/data/prefs/SecurePrefs.kt new file mode 100644 index 0000000..ae63601 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/prefs/SecurePrefs.kt @@ -0,0 +1,84 @@ +package com.homelab.ncal.data.prefs + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import java.security.SecureRandom + +/** + * Holds the Nextcloud server base URL + login used to build CalDAV requests. + * Use a per-user "app password" (Nextcloud Settings -> Security -> Devices & sessions + * -> Create new app password), never the account's real password. + */ +class SecurePrefs(context: Context) { + + private val prefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + context, + "ncal_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 username: String? + get() = prefs.getString(KEY_USERNAME, null) + set(value) = prefs.edit().putString(KEY_USERNAME, value).apply() + + var appPassword: String? + get() = prefs.getString(KEY_APP_PASSWORD, null) + set(value) = prefs.edit().putString(KEY_APP_PASSWORD, value).apply() + + /** Base URL for this user's CalDAV calendar home, e.g. https://host/remote.php/dav/calendars/anon/ */ + val calendarHomeUrl: String? + get() { + val server = serverUrl?.trimEnd('/') ?: return null + val user = username ?: return null + return "$server/remote.php/dav/calendars/$user/" + } + + val isLoggedIn: Boolean + get() = serverUrl != null && username != null && appPassword != null + + /** + * Random passphrase for the local SQLCipher-encrypted Room database, generated once and + * kept in the same Keystore-backed encrypted prefs as the server credentials. Deliberately + * NOT cleared by [clear] - logging out shouldn't strand the local DB with an unrecoverable + * key, since a re-login should be able to read the same cache back. + */ + val databaseKey: ByteArray + get() { + val existing = prefs.getString(KEY_DB_KEY, null) + if (existing != null) return Base64.decode(existing, Base64.NO_WRAP) + val generated = ByteArray(32).also { SecureRandom().nextBytes(it) } + prefs.edit().putString(KEY_DB_KEY, Base64.encodeToString(generated, Base64.NO_WRAP)).apply() + return generated + } + + /** Clears login state only - see [databaseKey] for why that survives a logout. */ + fun clear() { + prefs.edit() + .remove(KEY_SERVER_URL) + .remove(KEY_USERNAME) + .remove(KEY_APP_PASSWORD) + .apply() + } + + companion object { + private const val KEY_SERVER_URL = "server_url" + private const val KEY_USERNAME = "username" + private const val KEY_APP_PASSWORD = "app_password" + private const val KEY_DB_KEY = "database_key" + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt new file mode 100644 index 0000000..8e751e4 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt @@ -0,0 +1,214 @@ +package com.homelab.ncal.data.repository + +import android.content.Context +import com.homelab.ncal.data.db.AppDatabase +import com.homelab.ncal.data.db.CollectionEntity +import com.homelab.ncal.data.db.ItemEntity +import com.homelab.ncal.data.model.CalendarCollection +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import com.homelab.ncal.data.model.TaskStatus +import com.homelab.ncal.data.network.CalDavClient +import com.homelab.ncal.data.prefs.SecurePrefs +import com.homelab.ncal.notifications.ReminderScheduler +import com.homelab.ncal.util.IcsMapper +import com.homelab.ncal.widget.MonthGridWidget +import com.homelab.ncal.widget.NextTasksWidget +import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class NextcloudRepository(context: Context) { + + private val appContext = context.applicationContext + private val prefs = SecurePrefs(context) + private val db = AppDatabase.get(context, prefs.databaseKey) + + private fun client(): CalDavClient { + val server = prefs.serverUrl ?: error("Not logged in") + val user = prefs.username ?: error("Not logged in") + val pass = prefs.appPassword ?: error("Not logged in") + return CalDavClient(server, user, pass) + } + + val isLoggedIn: Boolean get() = prefs.isLoggedIn + + suspend fun login(serverUrl: String, username: String, appPassword: String) = withContext(Dispatchers.IO) { + // Throws CalDavException if the credentials/URL are bad. + CalDavClient(serverUrl, username, appPassword).testConnection() + prefs.serverUrl = serverUrl + prefs.username = username + prefs.appPassword = appPassword + } + + fun logout() { + prefs.clear() + } + + // ---------- Collections ---------- + + fun observeCollections(): Flow> = + db.collectionDao().observeAll().map { list -> list.map { it.toModel() } } + + suspend fun setCollectionEnabled(url: String, enabled: Boolean) = + db.collectionDao().setEnabled(url, enabled) + + /** Refreshes the list of calendars/task-lists from the server. */ + suspend fun syncCollections() = withContext(Dispatchers.IO) { + val remote = client().discoverCollections() + val existing = db.collectionDao().getAll().associateBy { it.url } + + val entities = remote.map { dav -> + val comps = dav.supportedComponents + CollectionEntity( + url = dav.href.toAbsolute(), + displayName = dav.displayName ?: dav.href.trimEnd('/').substringAfterLast('/'), + colorHex = dav.colorHex, + supportsEvents = comps.isEmpty() || comps.contains("VEVENT"), + supportsTasks = comps.contains("VTODO"), + ctag = dav.ctag, + enabled = existing[dav.href.toAbsolute()]?.enabled ?: true + ) + } + db.collectionDao().upsertAll(entities) + db.collectionDao().pruneMissing(entities.map { it.url }) + } + + // ---------- Items (events + tasks) ---------- + + fun observeItems(calendarUrls: List): Flow> = + db.itemDao().observeForCalendars(calendarUrls).map { list -> list.map { it.toModel() } } + + fun observeTasks(calendarUrls: List): Flow> = + db.itemDao().observeTasks(calendarUrls).map { list -> list.map { it.toModel() } } + + fun observeEvents(calendarUrls: List): Flow> = + db.itemDao().observeEvents(calendarUrls).map { list -> list.map { it.toModel() } } + + suspend fun itemByHref(href: String): CalendarItem? = withContext(Dispatchers.IO) { + db.itemDao().getByHref(href)?.toModel() + } + + suspend fun allCachedItems(): List = withContext(Dispatchers.IO) { + db.itemDao().getAllOnce().map { it.toModel() } + } + + /** Pulls fresh events + tasks for every enabled collection. */ + suspend fun syncItems() = withContext(Dispatchers.IO) { + val c = client() + val collections = db.collectionDao().getAll().filter { it.enabled } + android.util.Log.d("NCalSync", "Syncing ${collections.size} enabled collection(s): ${collections.map { "${it.displayName}(events=${it.supportsEvents},tasks=${it.supportsTasks})" }}") + + val startCal = java.util.Calendar.getInstance() + startCal.add(java.util.Calendar.DAY_OF_YEAR, -1) + val windowStart = startCal.time + + val endCal = java.util.Calendar.getInstance() + endCal.add(java.util.Calendar.YEAR, 1) + val windowEnd = endCal.time + + for (col in collections) { + val keepHrefs = mutableListOf() + + if (col.supportsEvents) { + val remote = c.fetchItems(col.url, "VEVENT", windowStart, windowEnd) + keepHrefs += remote.map { it.href.toAbsolute() } + val entities = remote.mapNotNull { dav -> + IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity() + } + android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VEVENT(s), parsed ${entities.size}") + db.itemDao().upsertAll(entities) + } + if (col.supportsTasks) { + val remote = c.fetchItems(col.url, "VTODO") + keepHrefs += remote.map { it.href.toAbsolute() } + val entities = remote.mapNotNull { dav -> + IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity() + } + android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VTODO(s), parsed ${entities.size}") + db.itemDao().upsertAll(entities) + } + if (col.supportsEvents || col.supportsTasks) { + db.itemDao().pruneMissing(col.url, keepHrefs) + } + } + + ReminderScheduler.rescheduleAll(appContext, db.itemDao().getAllOnce().map { it.toModel() }) + updateWidgets() + } + + suspend fun fullSync() { + syncCollections() + syncItems() + } + + /** Creates or updates an item both on the server and in the local cache. */ + suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) { + val c = client() + val ics = IcsMapper.toIcs(item) + + val saved = if (item.isNew) { + val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid) + item.copy(href = href, etag = etag, rawIcs = ics) + } else { + val etag = c.updateItem(item.href, ics, item.etag) + item.copy(etag = etag, rawIcs = ics) + } + db.itemDao().upsert(saved.toEntity()) + ReminderScheduler.reschedule(appContext, saved) + updateWidgets() + saved + } + + suspend fun delete(item: CalendarItem) = withContext(Dispatchers.IO) { + if (!item.isNew) { + client().deleteItem(item.href, item.etag) + } + db.itemDao().deleteByHref(item.href) + ReminderScheduler.cancel(appContext, item) + updateWidgets() + } + + suspend fun toggleTaskComplete(item: CalendarItem): CalendarItem = + save(item.withStatus(if (item.completed) TaskStatus.NEEDS_ACTION else TaskStatus.COMPLETED)) + + private suspend fun updateWidgets() { + MonthGridWidget().updateAll(appContext) + NextTasksWidget().updateAll(appContext) + } + + // ---------- mapping helpers ---------- + + private fun CollectionEntity.toModel() = CalendarCollection( + url = url, displayName = displayName, colorHex = colorHex, + supportsEvents = supportsEvents, supportsTasks = supportsTasks, ctag = ctag, enabled = enabled + ) + + private fun ItemEntity.toModel() = CalendarItem( + uid = uid, href = href, etag = etag, calendarUrl = calendarUrl, + type = ItemType.valueOf(type), summary = summary, description = description, location = location, url = url, + start = start, end = end, allDay = allDay, due = due, completed = completed, + status = runCatching { TaskStatus.valueOf(status) }.getOrDefault(TaskStatus.NEEDS_ACTION), + percentComplete = percentComplete, priority = priority, + parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(), rawIcs = rawIcs + ) + + private fun CalendarItem.toEntity() = ItemEntity( + href = href, uid = uid, etag = etag, calendarUrl = calendarUrl, type = type.name, + summary = summary, description = description, location = location, url = url, + start = start, end = end, allDay = allDay, due = due, completed = completed, + status = status.name, percentComplete = percentComplete, + priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(), rawIcs = rawIcs + ) + + private fun String.toMinutesList(): List = + if (isBlank()) emptyList() else split(",").mapNotNull { it.trim().toIntOrNull() } + + private fun List.toCsv(): String = joinToString(",") + + /** Server sometimes returns relative hrefs; normalize against the calendar home. */ + private fun String.toAbsolute(): String = + if (this.startsWith("http")) this else "${prefs.serverUrl?.trimEnd('/')}$this" +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/notifications/BootReceiver.kt b/ncal/app/src/main/java/com/homelab/ncal/notifications/BootReceiver.kt new file mode 100644 index 0000000..56f16d4 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/notifications/BootReceiver.kt @@ -0,0 +1,26 @@ +package com.homelab.ncal.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.homelab.ncal.NcalApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** Alarms don't survive a reboot or app update, so re-arm every cached item's reminder. */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val app = context.applicationContext as NcalApplication + if (!app.repository.isLoggedIn) return + + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + ReminderScheduler.rescheduleAll(context, app.repository.allCachedItems()) + } finally { + pendingResult.finish() + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderReceiver.kt b/ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderReceiver.kt new file mode 100644 index 0000000..5de4f00 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderReceiver.kt @@ -0,0 +1,85 @@ +package com.homelab.ncal.notifications + +import android.Manifest +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.homelab.ncal.MainActivity +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.R +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +const val CHANNEL_ID = "reminders" +const val EXTRA_HREF = "href" + +/** Fires at an item's next reminder time: shows a notification, then re-arms the following offset. */ +class ReminderReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val href = intent.getStringExtra(EXTRA_HREF) ?: return + val app = context.applicationContext as NcalApplication + val pendingResult = goAsync() + + CoroutineScope(Dispatchers.IO).launch { + try { + val item = app.repository.itemByHref(href) + if (item != null) { + showNotification(context, item) + ReminderScheduler.reschedule(context, item) + } + } finally { + pendingResult.finish() + } + } + } +} + +private fun showNotification(context: Context, item: CalendarItem) { + if (item.type == ItemType.TASK && item.completed) return + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + return + } + + val anchor = if (item.type == ItemType.EVENT) item.start else item.due + val contentText = anchor?.let { + val label = if (item.type == ItemType.EVENT) "Starts" else "Due" + "$label ${SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()).format(Date(it))}" + } ?: "" + + val contentIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name) + putExtra(MainActivity.EXTRA_ITEM_HREF, item.href) + } + val pendingIntent = PendingIntent.getActivity( + context, + item.href.hashCode(), + contentIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(item.summary) + .setContentText(contentText) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + NotificationManagerCompat.from(context).notify(item.href.hashCode(), notification) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderScheduler.kt b/ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderScheduler.kt new file mode 100644 index 0000000..95d565c --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/notifications/ReminderScheduler.kt @@ -0,0 +1,63 @@ +package com.homelab.ncal.notifications + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType + +/** + * Schedules a single exact alarm per item, always set for the item's *next* upcoming reminder + * offset. [ReminderReceiver] re-arms the following offset (if any) each time it fires, so there's + * never more than one pending alarm per item to track or cancel. + */ +object ReminderScheduler { + + fun cancel(context: Context, item: CalendarItem) { + if (item.href.isEmpty()) return + val pendingIntent = pendingIntentFor(context, item.href, create = false) ?: return + alarmManager(context).cancel(pendingIntent) + pendingIntent.cancel() + } + + fun reschedule(context: Context, item: CalendarItem) { + cancel(context, item) + if (item.href.isEmpty()) return + if (item.type == ItemType.TASK && item.completed) return + + val anchor = if (item.type == ItemType.EVENT) item.start else item.due + if (anchor == null || item.reminderMinutes.isEmpty()) return + + val now = System.currentTimeMillis() + val nextTrigger = item.reminderMinutes + .map { anchor - it * 60_000L } + .filter { it > now } + .minOrNull() ?: return + + val pendingIntent = pendingIntentFor(context, item.href, create = true) ?: return + val manager = alarmManager(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !manager.canScheduleExactAlarms()) { + manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTrigger, pendingIntent) + } else { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTrigger, pendingIntent) + } + } + + fun rescheduleAll(context: Context, items: List) { + items.forEach { reschedule(context, it) } + } + + private fun alarmManager(context: Context) = + context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + private fun pendingIntentFor(context: Context, href: String, create: Boolean): PendingIntent? { + val intent = Intent(context, ReminderReceiver::class.java).apply { + putExtra(EXTRA_HREF, href) + } + val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or + PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(context, href.hashCode(), intent, flags) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/ViewModelFactory.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/ViewModelFactory.kt new file mode 100644 index 0000000..2922863 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/ViewModelFactory.kt @@ -0,0 +1,24 @@ +package com.homelab.ncal.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.data.repository.NextcloudRepository + +/** + * Small hand-rolled factory so ViewModels can take a [NextcloudRepository] (and, where + * needed, the navigation [SavedStateHandle]) without pulling in a DI framework. + */ +class NcalViewModelFactory( + private val app: NcalApplication, + private val create: (NextcloudRepository, SavedStateHandle) -> ViewModel +) : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T { + val handle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return create(app.repository, handle) as T + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt new file mode 100644 index 0000000..a5c4f4e --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt @@ -0,0 +1,169 @@ +package com.homelab.ncal.ui.agenda + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgendaScreen( + viewModel: AgendaViewModel, + onOpenItem: (CalendarItem) -> Unit, + onNewEvent: () -> Unit, + onOpenTasks: () -> Unit, + onOpenCollections: () -> Unit, + onOpenMonth: () -> Unit +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.refresh() + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Agenda") }, + actions = { + IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") } + IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") } + IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") } + } + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) + ) + } + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.weight(1f) + ) { + if (state.itemsByDay.isEmpty() && !state.refreshing) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Nothing here yet. Pull to refresh, or check enabled calendars.") + } + } + LazyColumn(contentPadding = PaddingValues(bottom = 88.dp)) { + state.itemsByDay.forEach { (day, dayItems) -> + item(key = "header-$day") { + Text( + text = day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + items(dayItems, key = { "$day-${it.href.ifEmpty { it.uid }}" }) { agendaItem -> + AgendaRow( + item = agendaItem, + onClick = { onOpenItem(agendaItem) }, + onToggleComplete = { viewModel.toggleTask(agendaItem) } + ) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun AgendaRow( + item: CalendarItem, + onClick: () -> Unit, + onToggleComplete: () -> Unit +) { + ListItem( + modifier = Modifier.clickableCompat(onClick), + headlineContent = { + Text( + item.summary, + textDecoration = if (item.completed) TextDecoration.LineThrough else null + ) + }, + supportingContent = { + val time = item.start?.let { formatTime(it, item.allDay) } + ?: item.due?.let { "Due " + formatTime(it, false) } + time?.let { Text(it) } + }, + leadingContent = { + if (item.type == ItemType.TASK) { + Icon( + imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, + contentDescription = "Toggle complete", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickableCompat(onToggleComplete) + ) + } else { + Icon(Icons.Filled.Event, contentDescription = null) + } + } + ) +} + +private fun formatTime(millis: Long, allDay: Boolean): String { + val zone = java.time.ZoneId.systemDefault() + val instant = java.time.Instant.ofEpochMilli(millis) + return if (allDay) { + "All day" + } else { + instant.atZone(zone).format(DateTimeFormatter.ofPattern("h:mm a")) + } +} + +// Small helper so leading icons and rows are tappable without pulling in indication ripple boilerplate everywhere. +private fun Modifier.clickableCompat(onClick: () -> Unit): Modifier = + this.then(Modifier.clickable(onClick = onClick)) diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaViewModel.kt new file mode 100644 index 0000000..3eef6a9 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaViewModel.kt @@ -0,0 +1,73 @@ +package com.homelab.ncal.ui.agenda + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.util.groupByAllOccupiedDays +import com.homelab.ncal.util.toUserMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.ZoneId + +data class AgendaUiState( + val itemsByDay: List>> = emptyList(), + val refreshing: Boolean = false, + val error: String? = null +) + +class AgendaViewModel(private val repo: NextcloudRepository) : ViewModel() { + + private val refreshing = MutableStateFlow(false) + private val error = MutableStateFlow(null) + + private val items = repo.observeCollections() + .flatMapLatest { collections -> + val urls = collections.filter { it.enabled }.map { it.url } + repo.observeItems(urls) + } + + val state: StateFlow = combine(items, refreshing, error) { itemList, r, e -> + AgendaUiState(groupByDay(itemList), r, e) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AgendaUiState()) + + private fun groupByDay(items: List): List>> { + val zone = ZoneId.systemDefault() + return items.groupByAllOccupiedDays(zone) + .toSortedMap() + .map { (day, list) -> day to list.sortedBy { it.start ?: it.due } } + } + + fun refresh() { + refreshing.value = true + viewModelScope.launch { + try { + repo.fullSync() + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "Agenda fullSync failed", e) + error.value = e.toUserMessage() + } finally { + refreshing.value = false + } + } + } + + fun toggleTask(item: CalendarItem) { + viewModelScope.launch { + try { + repo.toggleTaskComplete(item) + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "toggleTask failed for href=${item.href} calendar=${item.calendarUrl}", e) + error.value = e.toUserMessage() + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt new file mode 100644 index 0000000..1d9ce81 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt @@ -0,0 +1,89 @@ +package com.homelab.ncal.ui.collections + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollectionsScreen( + viewModel: CollectionsViewModel, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Calendars & task lists") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } + }, + actions = { + IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "Refresh") } + } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + if (state.refreshing) { + CircularProgressIndicator(Modifier.padding(16.dp)) + } + state.error?.let { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + } + LazyColumn { + items(state.collections, key = { it.url }) { col -> + ListItem( + headlineContent = { Text(col.displayName) }, + supportingContent = { Text(componentLabel(col.supportsEvents, col.supportsTasks)) }, + leadingContent = { + val color = runCatching { Color(android.graphics.Color.parseColor(col.colorHex ?: "#1F6FEB")) } + .getOrDefault(MaterialTheme.colorScheme.primary) + Canvas(Modifier.size(16.dp)) { + drawCircle(color = color) + } + }, + trailingContent = { + Checkbox( + checked = col.enabled, + onCheckedChange = { viewModel.setEnabled(col.url, it) } + ) + } + ) + } + } + } + } +} + +private fun componentLabel(events: Boolean, tasks: Boolean): String = when { + events && tasks -> "Events + Tasks" + events -> "Events" + tasks -> "Tasks" + else -> "Unknown" +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt new file mode 100644 index 0000000..4f2a82d --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt @@ -0,0 +1,62 @@ +package com.homelab.ncal.ui.collections + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.model.CalendarCollection +import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.util.toUserMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class CollectionsUiState( + val collections: List = emptyList(), + val refreshing: Boolean = false, + val error: String? = null +) + +class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() { + + private val refreshing = MutableStateFlow(false) + private val error = MutableStateFlow(null) + + val state: StateFlow = combine( + repo.observeCollections(), refreshing, error + ) { collections, isRefreshing, err -> + CollectionsUiState(collections, isRefreshing, err) + }.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState()) + + fun refresh() { + refreshing.value = true + viewModelScope.launch { + try { + repo.fullSync() + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "Collections fullSync failed", e) + error.value = e.toUserMessage() + } finally { + refreshing.value = false + } + } + } + + fun setEnabled(url: String, enabled: Boolean) { + viewModelScope.launch { + repo.setCollectionEnabled(url, enabled) + if (enabled) { + // Newly-enabled collections (or ones re-enabled after being off) have never + // had their items fetched - without this they'd sit empty until the user + // happens to pull-to-refresh on Agenda/Tasks. + try { + repo.syncItems() + } catch (e: Exception) { + android.util.Log.e("NCalError", "syncItems after enabling $url failed", e) + error.value = e.toUserMessage() + } + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt new file mode 100644 index 0000000..fa1d3ef --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt @@ -0,0 +1,538 @@ +package com.homelab.ncal.ui.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +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.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.homelab.ncal.data.model.ItemType +import com.homelab.ncal.data.model.TaskStatus +import com.homelab.ncal.util.priorityColor +import com.homelab.ncal.util.priorityLabel +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemEditScreen( + viewModel: ItemEditViewModel, + onDone: () -> Unit +) { + val state by viewModel.state.collectAsState() + var showDeleteConfirm by remember { mutableStateOf(false) } + + LaunchedEffect(state.done, state.deleted) { + if (state.done || state.deleted) onDone() + } + + val item = state.item + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(if (item?.isNew == false) "Edit" else if (item?.type == ItemType.TASK) "New task" else "New event") }, + navigationIcon = { + IconButton(onClick = onDone) { Icon(Icons.Filled.ArrowBack, null) } + }, + actions = { + if (item != null && !item.isNew) { + IconButton(onClick = { showDeleteConfirm = true }) { + Icon(Icons.Filled.Delete, "Delete") + } + } + } + ) + } + ) { padding -> + if (state.loading || item == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + value = item.summary, + onValueChange = { v -> viewModel.update { it.copy(summary = v) } }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + + OutlinedTextField( + value = item.description, + onValueChange = { v -> viewModel.update { it.copy(description = v) } }, + label = { Text("Description") }, + minLines = 3, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + + OutlinedTextField( + value = item.location, + onValueChange = { v -> viewModel.update { it.copy(location = v) } }, + label = { Text("Location") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + + OutlinedTextField( + value = item.url, + onValueChange = { v -> viewModel.update { it.copy(url = v) } }, + label = { Text("URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + + Text("Priority", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(bottom = 4.dp)) + PriorityPicker( + selected = item.priority, + onSelect = { p -> viewModel.update { it.copy(priority = p) } } + ) + + if (item.type == ItemType.EVENT) { + Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp)) + DateTimePicker( + millis = item.start, + onChange = { v -> viewModel.update { it.copy(start = v) } } + ) + Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) + DateTimePicker( + millis = item.end, + onChange = { v -> viewModel.update { it.copy(end = v) } } + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp) + ) { + Checkbox( + checked = item.allDay, + onCheckedChange = { v -> viewModel.update { it.copy(allDay = v) } } + ) + Text("All day") + } + } else { + Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp)) + DateTimePicker( + millis = item.start, + onChange = { v -> viewModel.update { it.copy(start = v) } } + ) + Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) + DateTimePicker( + millis = item.due, + onChange = { v -> viewModel.update { it.copy(due = v) } } + ) + + Text( + "Status", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) + StatusPicker( + selected = item.status, + onSelect = { s -> viewModel.update { it.withStatus(s) } } + ) + + Text( + "% complete: ${item.percentComplete}", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 16.dp) + ) + Slider( + value = item.percentComplete.toFloat(), + onValueChange = { v -> + val pct = v.toInt() + viewModel.update { + if (pct >= 100) { + it.withStatus(TaskStatus.COMPLETED) + } else { + it.copy( + percentComplete = pct, + status = if (it.status == TaskStatus.COMPLETED) TaskStatus.IN_PROCESS else it.status, + completed = false + ) + } + } + }, + valueRange = 0f..100f, + steps = 19 + ) + + Text( + "Parent task", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) + ParentTaskPicker( + selectedUid = item.parentUid, + options = state.availableParentTasks, + onSelect = { uid -> viewModel.update { it.copy(parentUid = uid) } } + ) + } + + Text( + "Reminders", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) + item.reminderMinutes.sorted().forEach { minutes -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(reminderLabel(minutes)) + IconButton(onClick = { + viewModel.update { it.copy(reminderMinutes = it.reminderMinutes - minutes) } + }) { + Icon(Icons.Filled.Delete, contentDescription = "Remove reminder") + } + } + } + AddReminderButton( + existing = item.reminderMinutes, + onAdd = { minutes -> viewModel.update { it.copy(reminderMinutes = (it.reminderMinutes + minutes).distinct()) } } + ) + + Text( + "Calendar", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) + CalendarPicker( + selectedUrl = item.calendarUrl, + options = state.availableCalendars, + onSelect = { url -> viewModel.update { it.copy(calendarUrl = url) } } + ) + + state.error?.let { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 16.dp)) + } + + androidx.compose.material3.Button( + onClick = viewModel::save, + enabled = !state.saving, + modifier = Modifier.fillMaxWidth().padding(top = 24.dp) + ) { + if (state.saving) { + CircularProgressIndicator(modifier = Modifier.padding(4.dp)) + } else { + Text("Save") + } + } + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") }, + text = { Text("This can't be undone.") }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + viewModel.delete() + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CalendarPicker( + selectedUrl: String, + options: List, + onSelect: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedName = options.firstOrNull { it.url == selectedUrl }?.displayName ?: "Select calendar" + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { col -> + DropdownMenuItem( + text = { Text(col.displayName) }, + onClick = { + onSelect(col.url) + expanded = false + } + ) + } + } + } +} + +@Composable +private fun PriorityDot(color: Color?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(12.dp) + .clip(CircleShape) + .background(color ?: MaterialTheme.colorScheme.outlineVariant) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PriorityPicker(selected: Int, onSelect: (Int) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = priorityLabel(selected), + onValueChange = {}, + readOnly = true, + leadingIcon = { PriorityDot(priorityColor(selected)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + (0..9).forEach { p -> + DropdownMenuItem( + leadingIcon = { PriorityDot(priorityColor(p)) }, + text = { Text(priorityLabel(p)) }, + onClick = { + onSelect(p) + expanded = false + } + ) + } + } + } +} + +private fun statusLabel(status: TaskStatus): String = when (status) { + TaskStatus.NEEDS_ACTION -> "Not started" + TaskStatus.IN_PROCESS -> "In progress" + TaskStatus.COMPLETED -> "Completed" + TaskStatus.CANCELLED -> "Cancelled" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StatusPicker(selected: TaskStatus, onSelect: (TaskStatus) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = statusLabel(selected), + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + TaskStatus.entries.forEach { s -> + DropdownMenuItem( + text = { Text(statusLabel(s)) }, + onClick = { + onSelect(s) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ParentTaskPicker( + selectedUid: String?, + options: List, + onSelect: (String?) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedName = options.firstOrNull { it.uid == selectedUid }?.summary ?: "None" + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + onSelect(null) + expanded = false + } + ) + options.forEach { task -> + DropdownMenuItem( + text = { Text(task.summary) }, + onClick = { + onSelect(task.uid) + expanded = false + } + ) + } + } + } +} + +private val reminderPresets = listOf(0, 5, 15, 30, 60, 120, 1440, 2880, 10080) + +private fun reminderLabel(minutes: Int): String = when { + minutes <= 0 -> "At time of event" + minutes % 10080 == 0 -> "${minutes / 10080} week${if (minutes / 10080 > 1) "s" else ""} before" + minutes % 1440 == 0 -> "${minutes / 1440} day${if (minutes / 1440 > 1) "s" else ""} before" + minutes % 60 == 0 -> "${minutes / 60} hour${if (minutes / 60 > 1) "s" else ""} before" + else -> "$minutes minutes before" +} + +@Composable +private fun AddReminderButton(existing: List, onAdd: (Int) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val available = reminderPresets.filter { it !in existing } + + Box { + TextButton(onClick = { expanded = true }, enabled = available.isNotEmpty()) { + Text("+ Add reminder") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + available.forEach { minutes -> + DropdownMenuItem( + text = { Text(reminderLabel(minutes)) }, + onClick = { + onAdd(minutes) + expanded = false + } + ) + } + } + } +} + +/** Simple date + time picker built on the platform DatePickerDialog/TimePickerDialog. */ +@Composable +private fun DateTimePicker(millis: Long?, onChange: (Long?) -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val display = millis?.let { SimpleDateFormat("EEE, MMM d yyyy h:mm a", Locale.getDefault()).format(Date(it)) } ?: "Not set" + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text(display, modifier = Modifier.padding(vertical = 8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + if (millis != null) { + TextButton(onClick = { onChange(null) }) { + Text("Clear") + } + } + TextButton(onClick = { + val cal = Calendar.getInstance() + millis?.let { cal.timeInMillis = it } + android.app.DatePickerDialog(context, { _, y, m, d -> + cal.set(y, m, d) + android.app.TimePickerDialog(context, { _, h, min -> + cal.set(Calendar.HOUR_OF_DAY, h) + cal.set(Calendar.MINUTE, min) + cal.set(Calendar.SECOND, 0) + onChange(cal.timeInMillis) + }, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), false).show() + }, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show() + }) { + Text("Set") + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt new file mode 100644 index 0000000..0c376ab --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt @@ -0,0 +1,143 @@ +package com.homelab.ncal.ui.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.model.CalendarCollection +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.util.toUserMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.net.URLDecoder +import java.util.UUID + +data class ItemEditUiState( + val item: CalendarItem? = null, + val availableCalendars: List = emptyList(), + val allTasks: List = emptyList(), + val loading: Boolean = true, + val saving: Boolean = false, + val error: String? = null, + val done: Boolean = false, + val deleted: Boolean = false +) { + /** Tasks eligible to be set as this task's parent: same calendar, not itself, not one of its own descendants. */ + val availableParentTasks: List get() { + val current = item ?: return emptyList() + val descendants = mutableSetOf() + var frontier = setOf(current.uid) + while (frontier.isNotEmpty()) { + val children = allTasks.filter { it.parentUid != null && it.parentUid in frontier } + .map { it.uid }.toSet() - descendants + descendants += children + frontier = children + } + return allTasks.filter { + it.calendarUrl == current.calendarUrl && it.uid != current.uid && it.uid !in descendants + } + } +} + +class ItemEditViewModel( + private val repo: NextcloudRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val hrefArg: String? = savedStateHandle.get("href") + ?.let { if (it == "new") null else URLDecoder.decode(it, "UTF-8") } + private val typeArg = savedStateHandle.get("type") ?: "EVENT" + private val calendarUrlArg: String? = savedStateHandle.get("calendarUrl") + ?.let { URLDecoder.decode(it, "UTF-8") }.takeUnless { it.isNullOrBlank() || it == "none" } + + private val _state = MutableStateFlow(ItemEditUiState()) + val state: StateFlow = _state + + init { + viewModelScope.launch { + val collections = repo.observeCollections().first() + val relevantType = ItemType.valueOf(typeArg) + val eligibleCalendars = collections.filter { + if (relevantType == ItemType.EVENT) it.supportsEvents else it.supportsTasks + } + + val loaded = if (hrefArg != null) { + findItem(hrefArg, collections) + } else { + val targetCalendar = calendarUrlArg ?: eligibleCalendars.firstOrNull()?.url + CalendarItem( + uid = UUID.randomUUID().toString(), + href = "", + etag = null, + calendarUrl = targetCalendar ?: "", + type = relevantType, + summary = "", + start = if (relevantType == ItemType.EVENT) System.currentTimeMillis() else null + ) + } + + val allTasks = if (relevantType == ItemType.TASK) { + repo.observeTasks(collections.map { it.url }).first() + } else { + emptyList() + } + + _state.value = _state.value.copy( + item = loaded, + availableCalendars = eligibleCalendars, + allTasks = allTasks, + loading = false + ) + } + } + + private suspend fun findItem(href: String, collections: List): CalendarItem? { + // Items are cached locally; pull from whichever calendar's flow contains it. + val urls = collections.map { it.url } + return repo.observeItems(urls).first().firstOrNull { it.href == href } + } + + fun update(transform: (CalendarItem) -> CalendarItem) { + _state.value = _state.value.copy(item = _state.value.item?.let(transform)) + } + + fun save() { + val item = _state.value.item ?: return + if (item.summary.isBlank()) { + _state.value = _state.value.copy(error = "Title is required") + return + } + if (item.calendarUrl.isBlank()) { + _state.value = _state.value.copy(error = "Pick a calendar first") + return + } + _state.value = _state.value.copy(saving = true, error = null) + viewModelScope.launch { + try { + repo.save(item) + _state.value = _state.value.copy(saving = false, done = true) + } catch (e: Exception) { + android.util.Log.e("NCalError", "save() failed for href=${item.href} calendar=${item.calendarUrl}", e) + _state.value = _state.value.copy(saving = false, error = e.toUserMessage()) + } + } + } + + fun delete() { + val item = _state.value.item ?: return + if (item.isNew) return + _state.value = _state.value.copy(saving = true, error = null) + viewModelScope.launch { + try { + repo.delete(item) + _state.value = _state.value.copy(saving = false, deleted = true) + } catch (e: Exception) { + android.util.Log.e("NCalError", "delete() failed for href=${item.href} calendar=${item.calendarUrl}", e) + _state.value = _state.value.copy(saving = false, error = e.toUserMessage()) + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt new file mode 100644 index 0000000..8c992fa --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginScreen.kt @@ -0,0 +1,111 @@ +package com.homelab.ncal.ui.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun LoginScreen( + viewModel: LoginViewModel, + onLoggedIn: () -> Unit +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(state.success) { + if (state.success) onLoggedIn() + } + + Scaffold { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text("Connect to Nextcloud", style = MaterialTheme.typography.headlineSmall) + Text( + "Uses CalDAV directly \u2014 no third-party sync app needed.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp, top = 4.dp) + ) + + OutlinedTextField( + value = state.serverUrl, + onValueChange = viewModel::onServerUrlChange, + label = { Text("Server URL") }, + placeholder = { Text("nextcloud.yourdomain.com") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + OutlinedTextField( + value = state.username, + onValueChange = viewModel::onUsernameChange, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) + OutlinedTextField( + value = state.appPassword, + onValueChange = viewModel::onPasswordChange, + label = { Text("App password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp) + ) + Text( + "Settings \u2192 Security \u2192 Devices & sessions \u2192 Create new app password. Don't use your real login password.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 20.dp) + ) + + state.error?.let { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 12.dp)) + } + + Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { + if (state.loading) { + CircularProgressIndicator(modifier = Modifier.padding(4.dp)) + } else { + Text("Connect") + } + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..4a985d3 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt @@ -0,0 +1,61 @@ +package com.homelab.ncal.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.repository.NextcloudRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +data class LoginUiState( + val serverUrl: String = "", + val username: String = "", + val appPassword: String = "", + val loading: Boolean = false, + val error: String? = null, + val success: Boolean = false +) + +class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() { + + private val _state = MutableStateFlow(LoginUiState()) + val state: StateFlow = _state + + fun onServerUrlChange(v: String) { _state.value = _state.value.copy(serverUrl = v, error = null) } + fun onUsernameChange(v: String) { _state.value = _state.value.copy(username = v, error = null) } + fun onPasswordChange(v: String) { _state.value = _state.value.copy(appPassword = v, error = null) } + + fun submit() { + val s = _state.value + if (s.serverUrl.isBlank() || s.username.isBlank() || s.appPassword.isBlank()) { + _state.value = s.copy(error = "All fields are required") + return + } + _state.value = s.copy(loading = true, error = null) + viewModelScope.launch { + try { + // Cleartext traffic is disabled app-wide (see AndroidManifest usesCleartextTraffic), + // so always force https regardless of what scheme (if any) the user typed. + val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://") + repo.login(normalized, s.username, s.appPassword) + repo.fullSync() + _state.value = _state.value.copy(loading = false, success = true) + } catch (e: Exception) { + android.util.Log.e("NCalLogin", "Login/sync failed", e) + _state.value = _state.value.copy( + loading = false, + error = "Couldn't connect: ${describe(e)}" + ) + } + } + } + + /** Turns common low-level exceptions (which often have a null .message) into something readable. */ + private fun describe(e: Exception): String = when (e) { + is java.net.UnknownHostException -> "Can't resolve that host \u2014 check the server URL and your network/DNS" + is java.net.ConnectException -> "Connection refused \u2014 is the server reachable from this device/emulator?" + is java.net.SocketTimeoutException -> "Connection timed out \u2014 check the URL and that the server is reachable" + is javax.net.ssl.SSLHandshakeException -> "TLS/certificate error \u2014 self-signed certs aren't trusted by default (${e.message ?: e.javaClass.simpleName})" + else -> e.message ?: "${e.javaClass.simpleName} (see Logcat tag NCalLogin for details)" + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt new file mode 100644 index 0000000..ebc0d78 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthScreen.kt @@ -0,0 +1,374 @@ +package com.homelab.ncal.ui.month + +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import com.homelab.ncal.util.WeekBar +import com.homelab.ncal.util.lanesForWeek +import com.homelab.ncal.util.weeksForMonth +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.WeekFields +import java.util.Locale + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun MonthScreen( + viewModel: MonthViewModel, + onOpenItem: (CalendarItem) -> Unit, + onNewEvent: () -> Unit, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(state.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + " " + state.month.year) }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } + }, + actions = { + IconButton(onClick = viewModel::goToToday) { Icon(Icons.Filled.Today, "Today") } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") } + } + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.padding(padding).fillMaxSize() + ) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) + ) + } + MonthHeader(onPrev = viewModel::prevMonth, onNext = viewModel::nextMonth) + WeekdayLabels() + MonthGrid( + month = state.month, + selectedDay = state.selectedDay, + itemsByDay = state.itemsByDay, + onDayClick = viewModel::selectDay, + onItemClick = onOpenItem, + onToggleTask = viewModel::toggleTask + ) + HorizontalDivider() + DayAgenda( + day = state.selectedDay, + items = state.itemsByDay[state.selectedDay].orEmpty(), + onOpenItem = onOpenItem, + onToggleTask = viewModel::toggleTask + ) + } + } + } +} + +@Composable +private fun MonthHeader(onPrev: () -> Unit, onNext: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onPrev) { Icon(Icons.Filled.ChevronLeft, "Previous month") } + IconButton(onClick = onNext) { Icon(Icons.Filled.ChevronRight, "Next month") } + } +} + +@Composable +private fun WeekdayLabels() { + val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val days = (0..6).map { firstDow.plus(it.toLong()) } + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { + days.forEach { dow -> + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text( + dow.getDisplayName(TextStyle.SHORT, Locale.getDefault()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +// ---------- Grid: one Column of week rows, each week a day-number strip + stacked event-bar lanes ---------- + +@Composable +private fun MonthGrid( + month: YearMonth, + selectedDay: LocalDate, + itemsByDay: Map>, + onDayClick: (LocalDate) -> Unit, + onItemClick: (CalendarItem) -> Unit, + onToggleTask: (CalendarItem) -> Unit +) { + val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val weeks = weeksForMonth(month, firstDow) + val today = LocalDate.now() + + Column(Modifier.fillMaxWidth()) { + weeks.forEach { week -> + WeekRow( + week = week, + month = month, + itemsByDay = itemsByDay, + selectedDay = selectedDay, + today = today, + onDayClick = onDayClick, + onItemClick = onItemClick, + onToggleTask = onToggleTask + ) + } + } +} + +@Composable +private fun WeekRow( + week: List, + month: YearMonth, + itemsByDay: Map>, + selectedDay: LocalDate, + today: LocalDate, + onDayClick: (LocalDate) -> Unit, + onItemClick: (CalendarItem) -> Unit, + onToggleTask: (CalendarItem) -> Unit +) { + val lanes = lanesForWeek(week, itemsByDay) + + Column(Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + Row(Modifier.fillMaxWidth()) { + week.forEach { date -> + val inMonth = YearMonth.from(date) == month + val isSelected = date == selectedDay + val isToday = date == today + Box( + Modifier.weight(1f).clickable { onDayClick(date) }.padding(vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Box( + Modifier.size(24.dp).clip(CircleShape).background( + when { + isSelected -> MaterialTheme.colorScheme.primary + isToday -> MaterialTheme.colorScheme.primaryContainer + else -> Color.Transparent + } + ), + contentAlignment = Alignment.Center + ) { + Text( + date.dayOfMonth.toString(), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal, + color = when { + isSelected -> MaterialTheme.colorScheme.onPrimary + !inMonth -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + else -> MaterialTheme.colorScheme.onSurface + } + ) + } + } + } + } + lanes.forEach { lane -> + Row(Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp)) { + var col = 0 + while (col < 7) { + val bar = lane.firstOrNull { it.startCol == col } + if (bar != null) { + GridItemRow( + item = bar.item, + modifier = Modifier.weight(bar.span.toFloat()).padding(horizontal = 1.dp), + onClick = { onItemClick(bar.item) }, + onToggleTask = { onToggleTask(bar.item) } + ) + col += bar.span + } else { + Spacer(Modifier.weight(1f)) + col += 1 + } + } + } + } + } +} + +/** + * Events render as a solid colored pill (they occupy time / span days). Tasks render as + * plain text with a small leading dot - no colored background - matching how Nextcloud/ + * Google Calendar visually distinguish "things with duration" from "things to check off". + * A completed task drops the dot entirely and just shows dimmed, struck-through text. + */ +@Composable +private fun GridItemRow( + item: CalendarItem, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onToggleTask: () -> Unit +) { + if (item.type == ItemType.TASK) { + Row( + modifier + .height(18.dp) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!item.completed) { + Box( + Modifier + .size(5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + .clickable(onClick = onToggleTask) + ) + Spacer(Modifier.size(3.dp)) + } + Text( + item.summary, + color = if (item.completed) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (item.completed) TextDecoration.LineThrough else null, + modifier = Modifier.clickable(onClick = onToggleTask) + ) + } + } else { + Box( + modifier + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = if (item.completed) 0.45f else 1f)) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + item.summary, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (item.completed) TextDecoration.LineThrough else null + ) + } + } +} + +// ---------- Selected-day agenda strip below the grid ---------- + +@Composable +private fun DayAgenda( + day: LocalDate, + items: List, + onOpenItem: (CalendarItem) -> Unit, + onToggleTask: (CalendarItem) -> Unit +) { + Column { + Text( + day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + if (items.isEmpty()) { + Text( + "Nothing scheduled", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + items.forEach { item -> + ListItem( + modifier = Modifier.clickable { onOpenItem(item) }, + headlineContent = { + Text(item.summary, textDecoration = if (item.completed) TextDecoration.LineThrough else null) + }, + supportingContent = { + val time = item.start?.let { formatTime(it, item.allDay) } + ?: item.due?.let { "Due " + formatTime(it, false) } + time?.let { Text(it) } + }, + leadingContent = { + if (item.type == ItemType.TASK) { + Icon( + imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, + contentDescription = "Toggle complete", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { onToggleTask(item) } + ) + } else { + Icon(Icons.Filled.Event, contentDescription = null) + } + } + ) + HorizontalDivider() + } + } +} + +private fun formatTime(millis: Long, allDay: Boolean): String { + val zone = java.time.ZoneId.systemDefault() + val instant = java.time.Instant.ofEpochMilli(millis) + return if (allDay) "All day" else instant.atZone(zone).format(DateTimeFormatter.ofPattern("h:mm a")) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthViewModel.kt new file mode 100644 index 0000000..c1a1523 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/month/MonthViewModel.kt @@ -0,0 +1,83 @@ +package com.homelab.ncal.ui.month + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.util.groupByAllOccupiedDays +import com.homelab.ncal.util.toUserMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId + +data class MonthUiState( + val month: YearMonth = YearMonth.now(), + val selectedDay: LocalDate = LocalDate.now(), + val itemsByDay: Map> = emptyMap(), + val refreshing: Boolean = false, + val error: String? = null +) + +class MonthViewModel(private val repo: NextcloudRepository) : ViewModel() { + + private val month = MutableStateFlow(YearMonth.now()) + private val selectedDay = MutableStateFlow(LocalDate.now()) + private val refreshing = MutableStateFlow(false) + private val error = MutableStateFlow(null) + + private val zone = ZoneId.systemDefault() + + private val allItems = repo.observeCollections() + .flatMapLatest { collections -> + val urls = collections.filter { it.enabled }.map { it.url } + repo.observeItems(urls) + } + + val state: StateFlow = combine(allItems, month, selectedDay, refreshing, error) { items, m, day, r, e -> + MonthUiState(month = m, selectedDay = day, itemsByDay = items.groupByAllOccupiedDays(zone), refreshing = r, error = e) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MonthUiState()) + + fun nextMonth() { month.value = month.value.plusMonths(1) } + fun prevMonth() { month.value = month.value.minusMonths(1) } + fun goToToday() { + month.value = YearMonth.now() + selectedDay.value = LocalDate.now() + } + fun selectDay(day: LocalDate) { + selectedDay.value = day + if (YearMonth.from(day) != month.value) month.value = YearMonth.from(day) + } + + fun toggleTask(item: CalendarItem) { + viewModelScope.launch { + try { + repo.toggleTaskComplete(item) + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "toggleTask failed for href=${item.href} calendar=${item.calendarUrl}", e) + error.value = e.toUserMessage() + } + } + } + + fun refresh() { + refreshing.value = true + viewModelScope.launch { + try { + repo.fullSync() + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "Month fullSync failed", e) + error.value = e.toUserMessage() + } + refreshing.value = false + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt new file mode 100644 index 0000000..674eed9 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt @@ -0,0 +1,124 @@ +package com.homelab.ncal.ui.nav + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.ui.NcalViewModelFactory +import com.homelab.ncal.ui.agenda.AgendaScreen +import com.homelab.ncal.ui.agenda.AgendaViewModel +import com.homelab.ncal.ui.collections.CollectionsScreen +import com.homelab.ncal.ui.collections.CollectionsViewModel +import com.homelab.ncal.ui.detail.ItemEditScreen +import com.homelab.ncal.ui.detail.ItemEditViewModel +import com.homelab.ncal.ui.login.LoginScreen +import com.homelab.ncal.ui.login.LoginViewModel +import com.homelab.ncal.ui.month.MonthScreen +import com.homelab.ncal.ui.month.MonthViewModel +import com.homelab.ncal.ui.tasks.TasksScreen +import com.homelab.ncal.ui.tasks.TasksViewModel +import java.net.URLEncoder + +private object Routes { + const val LOGIN = "login" + const val AGENDA = "agenda" + const val TASKS = "tasks" + const val MONTH = "month" + const val COLLECTIONS = "collections" + const val EDIT = "edit/{type}/{href}/{calendarUrl}" +} + +private fun editRoute(type: String, href: String?, calendarUrl: String? = null): String { + val encodedHref = URLEncoder.encode(href ?: "new", "UTF-8") + val encodedCalUrl = URLEncoder.encode(calendarUrl ?: "none", "UTF-8") + return "edit/$type/$encodedHref/$encodedCalUrl" +} + +@Composable +fun NcalNavGraph( + app: NcalApplication, + pendingDeepLink: Pair? = null, + onDeepLinkConsumed: () -> Unit = {} +) { + val navController = rememberNavController() + val startDestination = if (app.repository.isLoggedIn) Routes.AGENDA else Routes.LOGIN + + LaunchedEffect(pendingDeepLink) { + pendingDeepLink?.let { (type, href) -> + navController.navigate(editRoute(type, href)) + onDeepLinkConsumed() + } + } + + NavHost(navController = navController, startDestination = startDestination) { + + composable(Routes.LOGIN) { + val vm: LoginViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> LoginViewModel(repo) }) + LoginScreen( + viewModel = vm, + onLoggedIn = { + navController.navigate(Routes.AGENDA) { + popUpTo(Routes.LOGIN) { inclusive = true } + } + } + ) + } + + composable(Routes.AGENDA) { + val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) }) + AgendaScreen( + viewModel = vm, + onOpenItem = { item: CalendarItem -> + navController.navigate(editRoute(item.type.name, item.href)) + }, + onNewEvent = { navController.navigate(editRoute("EVENT", null)) }, + onOpenTasks = { navController.navigate(Routes.TASKS) }, + onOpenCollections = { navController.navigate(Routes.COLLECTIONS) }, + onOpenMonth = { navController.navigate(Routes.MONTH) } + ) + } + + composable(Routes.MONTH) { + val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) }) + MonthScreen( + viewModel = vm, + onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) }, + onNewEvent = { navController.navigate(editRoute("EVENT", null)) }, + onBack = { navController.popBackStack() } + ) + } + + composable(Routes.TASKS) { + val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) }) + TasksScreen( + viewModel = vm, + onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) }, + onNewTask = { navController.navigate(editRoute("TASK", null)) }, + onBack = { navController.popBackStack() } + ) + } + + composable(Routes.COLLECTIONS) { + val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) }) + CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() }) + } + + composable( + route = Routes.EDIT, + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("href") { type = NavType.StringType }, + navArgument("calendarUrl") { type = NavType.StringType } + ) + ) { + val vm: ItemEditViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, handle -> ItemEditViewModel(repo, handle) }) + ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() }) + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt new file mode 100644 index 0000000..8be5050 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksScreen.kt @@ -0,0 +1,108 @@ +package com.homelab.ncal.ui.tasks + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.util.priorityColor +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TasksScreen( + viewModel: TasksViewModel, + onOpenTask: (CalendarItem) -> Unit, + onNewTask: () -> Unit, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Tasks") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) } } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onNewTask) { Icon(Icons.Filled.Add, "New task") } + } + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + state.error?.let { + Text( + it, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(16.dp) + ) + } + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = viewModel::refresh, + modifier = Modifier.weight(1f) + ) { + LazyColumn { + items(state.tasks, key = { it.item.href.ifEmpty { it.item.uid } }) { node -> + val task = node.item + ListItem( + modifier = Modifier + .padding(start = (node.depth * 24).dp) + .clickable { onOpenTask(task) }, + headlineContent = { + Text( + task.summary, + textDecoration = if (task.completed) TextDecoration.LineThrough else null + ) + }, + supportingContent = { + task.due?.let { Text("Due " + it.toFormattedDate()) } + }, + leadingContent = { + Icon( + imageVector = if (task.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, + contentDescription = "Toggle complete", + tint = if (task.completed) MaterialTheme.colorScheme.primary else (priorityColor(task.priority) ?: MaterialTheme.colorScheme.primary), + modifier = Modifier.clickable { viewModel.toggle(task) } + ) + } + ) + HorizontalDivider() + } + } + } + } + } +} + +private fun Long.toFormattedDate(): String = + Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("MMM d, h:mm a")) diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt new file mode 100644 index 0000000..363a0cd --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt @@ -0,0 +1,89 @@ +package com.homelab.ncal.ui.tasks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.util.toUserMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** A task plus how many levels deep it is nested under its ancestors (0 = top-level). */ +data class TaskNode(val item: CalendarItem, val depth: Int) + +data class TasksUiState( + val tasks: List = emptyList(), + val refreshing: Boolean = false, + val error: String? = null +) + +class TasksViewModel(private val repo: NextcloudRepository) : ViewModel() { + + private val refreshing = MutableStateFlow(false) + private val error = MutableStateFlow(null) + + private val tasks = repo.observeCollections() + .flatMapLatest { collections -> + val urls = collections.filter { it.enabled }.map { it.url } + repo.observeTasks(urls) + } + + val state: StateFlow = combine(tasks, refreshing, error) { taskList, r, e -> + TasksUiState(buildTree(taskList), r, e) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TasksUiState()) + + /** Orders tasks depth-first under their parent (via [CalendarItem.parentUid]), preserving each + * level's original sort order. Broken/cyclic parent references fall back to top-level. */ + private fun buildTree(tasks: List): List { + val allUids = tasks.map { it.uid }.toSet() + val childrenByParent = tasks.groupBy { it.parentUid } + val visited = mutableSetOf() + val result = mutableListOf() + + fun addWithChildren(task: CalendarItem, depth: Int) { + if (!visited.add(task.uid)) return + result += TaskNode(task, depth) + childrenByParent[task.uid].orEmpty().forEach { addWithChildren(it, depth + 1) } + } + + tasks.forEach { task -> + val isRoot = task.parentUid == null || task.parentUid !in allUids + if (isRoot) addWithChildren(task, 0) + } + tasks.forEach { task -> if (task.uid !in visited) addWithChildren(task, 0) } + + return result + } + + fun toggle(item: CalendarItem) { + viewModelScope.launch { + try { + repo.toggleTaskComplete(item) + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "toggle failed for href=${item.href} calendar=${item.calendarUrl}", e) + error.value = e.toUserMessage() + } + } + } + + fun refresh() { + refreshing.value = true + viewModelScope.launch { + try { + repo.syncItems() + error.value = null + } catch (e: Exception) { + android.util.Log.e("NCalError", "Tasks syncItems failed", e) + error.value = e.toUserMessage() + } finally { + refreshing.value = false + } + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/theme/Theme.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/theme/Theme.kt new file mode 100644 index 0000000..90ccddf --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/theme/Theme.kt @@ -0,0 +1,38 @@ +package com.homelab.ncal.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val NcalBlue = Color(0xFF1F6FEB) + +private val LightColors = lightColorScheme(primary = NcalBlue) +private val DarkColors = darkColorScheme(primary = Color(0xFF6EA8FE)) + +@Composable +fun NCalTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/DateRangeUtils.kt b/ncal/app/src/main/java/com/homelab/ncal/util/DateRangeUtils.kt new file mode 100644 index 0000000..e8af5a5 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/DateRangeUtils.kt @@ -0,0 +1,37 @@ +package com.homelab.ncal.util + +import com.homelab.ncal.data.model.CalendarItem +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * The inclusive [LocalDate] range this item occupies on a calendar grid, in the device's + * local time zone. Multi-day all-day events store DTEND as the day *after* the last day + * per the iCalendar spec (an exclusive end) - this accounts for that so a 3-night trip + * shows on all 3 days, not just the day after it ends. + */ +fun CalendarItem.dateRange(zone: ZoneId = ZoneId.systemDefault()): ClosedRange? { + val startMillis = start ?: due ?: return null + val startDate = Instant.ofEpochMilli(startMillis).atZone(zone).toLocalDate() + val endDate = end?.let { + val e = Instant.ofEpochMilli(it).atZone(zone).toLocalDate() + if (allDay && e.isAfter(startDate)) e.minusDays(1) else e + } ?: startDate + return startDate..maxOf(startDate, endDate) +} + +/** Groups items by every day they occupy (not just their start day). */ +fun List.groupByAllOccupiedDays(zone: ZoneId = ZoneId.systemDefault()): Map> { + val result = mutableMapOf>() + for (item in this) { + val range = item.dateRange(zone) ?: continue + var d = range.start + var guard = 0 + while (!d.isAfter(range.endInclusive) && guard++ < 400) { + result.getOrPut(d) { mutableListOf() }.add(item) + d = d.plusDays(1) + } + } + return result +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt b/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt new file mode 100644 index 0000000..e13bb67 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt @@ -0,0 +1,20 @@ +package com.homelab.ncal.util + +import com.homelab.ncal.data.network.CalDavException + +/** + * Short, user-facing summary for an error banner. [CalDavException] messages carry the full + * request URL and up to 300 chars of raw server response (useful in logs, but it reads like a + * crash dump if shown directly in the UI) - this collapses that down to one line. + */ +fun Throwable.toUserMessage(): String = when (this) { + is CalDavException -> when (httpCode) { + 404 -> "Server couldn't find that item - it may have changed or been removed elsewhere." + 401 -> "Login rejected - your stored credentials may have expired. Try logging in again." + 403 -> "Server refused that change - this calendar may not allow it." + in 500..599 -> "Server error ($httpCode). Try again later." + null -> "Couldn't reach the server. Check your connection." + else -> "Server error ($httpCode)." + } + else -> message?.takeIf { it.isNotBlank() } ?: "Something went wrong." +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt new file mode 100644 index 0000000..64c23fc --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt @@ -0,0 +1,193 @@ +package com.homelab.ncal.util + +import biweekly.Biweekly +import biweekly.ICalendar +import biweekly.component.VAlarm +import biweekly.component.VEvent +import biweekly.component.VTodo +import biweekly.parameter.Related +import biweekly.property.RelatedTo +import biweekly.property.Status +import biweekly.property.Trigger +import biweekly.util.Duration +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import com.homelab.ncal.data.model.TaskStatus +import java.util.Date +import java.util.UUID + +object IcsMapper { + + private fun Status?.toTaskStatus(): TaskStatus = when { + this == null -> TaskStatus.NEEDS_ACTION + isCompleted -> TaskStatus.COMPLETED + isCancelled -> TaskStatus.CANCELLED + isInProgress -> TaskStatus.IN_PROCESS + else -> TaskStatus.NEEDS_ACTION + } + + private fun TaskStatus.toIcsStatus(): Status = when (this) { + TaskStatus.NEEDS_ACTION -> Status.needsAction() + TaskStatus.IN_PROCESS -> Status.inProgress() + TaskStatus.COMPLETED -> Status.completed() + TaskStatus.CANCELLED -> Status.cancelled() + } + + /** Reads the "minutes before" values out of any duration-relative VALARMs on the component. */ + private fun alarmMinutesBefore(alarms: List): List = + alarms.mapNotNull { alarm -> + val duration = alarm.trigger?.duration ?: return@mapNotNull null + if (!duration.isPrior) return@mapNotNull null + (-duration.toMillis() / 60_000L).toInt() + }.distinct().sorted() + + /** Parses a raw .ics blob (one VEVENT or VTODO) fetched from the server into our model. */ + fun parse(ics: String, href: String, etag: String?, calendarUrl: String): CalendarItem? { + val ical = Biweekly.parse(ics).first() ?: return null + + ical.events.firstOrNull()?.let { ev -> + val originalStart = ev.dateStart?.value?.time + val originalEnd = ev.dateEnd?.value?.time + val durationMillis = if (originalStart != null && originalEnd != null) originalEnd - originalStart else null + + val recurrence = ev.recurrenceRule?.value + val (resolvedStart, resolvedEnd) = if (recurrence != null && originalStart != null) { + val next = RecurrenceUtils.nextOccurrence(originalStart, recurrence, System.currentTimeMillis()) + if (next == null) { + android.util.Log.d("NCalParse", "EVENT '${ev.summary?.value}' recurrence ended (freq=${recurrence.frequency}, until=${recurrence.until}, count=${recurrence.count}) - dropping") + return null + } + next to (durationMillis?.let { next + it } ?: next) + } else { + originalStart to originalEnd + } + + android.util.Log.d( + "NCalParse", + "EVENT '${ev.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " + + "originalStart=${originalStart?.let { java.util.Date(it) }} resolvedStart=${resolvedStart?.let { java.util.Date(it) }}" + ) + + return CalendarItem( + uid = ev.uid?.value ?: UUID.randomUUID().toString(), + href = href, + etag = etag, + calendarUrl = calendarUrl, + type = ItemType.EVENT, + summary = ev.summary?.value ?: "(no title)", + description = ev.description?.value ?: "", + location = ev.location?.value ?: "", + url = ev.url?.value ?: "", + start = resolvedStart, + end = resolvedEnd, + allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false, + priority = ev.priority?.value ?: 0, + reminderMinutes = alarmMinutesBefore(ev.alarms), + rawIcs = ics + ) + } + + ical.todos.firstOrNull()?.let { td -> + val originalDue = td.dateDue?.value?.time + val originalStart = td.dateStart?.value?.time + val startToDueMillis = if (originalStart != null && originalDue != null) originalDue - originalStart else null + + val recurrence = td.recurrenceRule?.value + val resolvedDue = if (recurrence != null && originalDue != null) { + RecurrenceUtils.nextOccurrence(originalDue, recurrence, System.currentTimeMillis()) + } else { + originalDue + } + val resolvedStart = if (recurrence != null && originalStart != null && resolvedDue != null) { + startToDueMillis?.let { resolvedDue - it } + } else { + originalStart + } + + android.util.Log.d( + "NCalParse", + "TASK '${td.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " + + "originalDue=${originalDue?.let { java.util.Date(it) }} resolvedDue=${resolvedDue?.let { java.util.Date(it) }} " + + "originalStart=${originalStart?.let { java.util.Date(it) }} completed=${td.status?.isCompleted}" + ) + + val status = td.status.toTaskStatus() + val percentComplete = td.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0) + + return CalendarItem( + uid = td.uid?.value ?: UUID.randomUUID().toString(), + href = href, + etag = etag, + calendarUrl = calendarUrl, + type = ItemType.TASK, + summary = td.summary?.value ?: "(no title)", + description = td.description?.value ?: "", + location = td.location?.value ?: "", + url = td.url?.value ?: "", + start = resolvedStart, + due = resolvedDue, + completed = status == TaskStatus.COMPLETED || percentComplete >= 100, + status = status, + percentComplete = percentComplete, + priority = td.priority?.value ?: 0, + parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value, + reminderMinutes = alarmMinutesBefore(td.alarms), + rawIcs = ics + ) + } + + android.util.Log.d("NCalParse", "FAILED to parse item at $href in calendar $calendarUrl - no VEVENT or VTODO found. Raw ics (first 300 chars): ${ics.take(300)}") + return null + } + + /** Builds a full .ics document ready to PUT to the server for the given item. */ + fun toIcs(item: CalendarItem): String { + val ical = ICalendar() + when (item.type) { + ItemType.EVENT -> { + val ev = VEvent() + ev.setUid(item.uid) + ev.setSummary(item.summary) + if (item.description.isNotBlank()) ev.setDescription(item.description) + if (item.location.isNotBlank()) ev.setLocation(item.location) + if (item.url.isNotBlank()) ev.setUrl(item.url) + item.start?.let { ev.setDateStart(Date(it), !item.allDay) } + item.end?.let { ev.setDateEnd(Date(it), !item.allDay) } + if (item.priority > 0) ev.setPriority(item.priority) + item.reminderMinutes.forEach { minutes -> + val duration = Duration.builder().prior(true).minutes(minutes).build() + ev.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary)) + } + ical.addEvent(ev) + } + ItemType.TASK -> { + val td = VTodo() + td.setUid(item.uid) + td.setSummary(item.summary) + if (item.description.isNotBlank()) td.setDescription(item.description) + if (item.location.isNotBlank()) td.setLocation(item.location) + if (item.url.isNotBlank()) td.setUrl(item.url) + item.start?.let { td.setDateStart(Date(it)) } + item.due?.let { td.setDateDue(Date(it)) } + if (item.priority > 0) td.setPriority(item.priority) + td.setStatus(item.status.toIcsStatus()) + if (item.percentComplete > 0) td.setPercentComplete(item.percentComplete) + item.parentUid?.takeIf { it.isNotBlank() }?.let { td.addRelatedTo(RelatedTo(it)) } + if (item.due != null) { + item.reminderMinutes.forEach { minutes -> + val duration = Duration.builder().prior(true).minutes(minutes).build() + td.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary)) + } + } + ical.addTodo(td) + } + } + return Biweekly.write(ical).go() + } + + /** Crude but effective: DTSTART/DTEND with a VALUE=DATE param (no time) means an all-day item. */ + private fun hasTimeComponent(ics: String, property: String): Boolean { + val line = ics.lines().firstOrNull { it.startsWith(property) } ?: return true + return !line.contains("VALUE=DATE") + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/MonthGridLayout.kt b/ncal/app/src/main/java/com/homelab/ncal/util/MonthGridLayout.kt new file mode 100644 index 0000000..ea26f33 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/MonthGridLayout.kt @@ -0,0 +1,68 @@ +package com.homelab.ncal.util + +import com.homelab.ncal.data.model.CalendarItem +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.temporal.ChronoUnit + +/** + * Shared month-grid layout algorithm used by both the in-app Month screen and the home + * screen widget, so the two render identical week rows and event-bar packing. + */ +const val MAX_LANES_PER_WEEK = 4 + +fun weeksForMonth(month: YearMonth, firstDow: DayOfWeek): List> { + val firstOfMonth = month.atDay(1) + val leadingBlanks = ((firstOfMonth.dayOfWeek.value - firstDow.value) + 7) % 7 + val gridStart = firstOfMonth.minusDays(leadingBlanks.toLong()) + val totalCells = leadingBlanks + month.lengthOfMonth() + val rows = (totalCells + 6) / 7 + return (0 until rows).map { row -> (0 until 7).map { col -> gridStart.plusDays((row * 7 + col).toLong()) } } +} + +data class WeekBar(val item: CalendarItem, val startCol: Int, val span: Int) + +fun barFor(item: CalendarItem, week: List): WeekBar? { + val range = item.dateRange() ?: return null + val weekStart = week.first() + val weekEnd = week.last() + if (range.endInclusive.isBefore(weekStart) || range.start.isAfter(weekEnd)) return null + val clippedStart = maxOf(range.start, weekStart) + val clippedEnd = minOf(range.endInclusive, weekEnd) + val startCol = ChronoUnit.DAYS.between(weekStart, clippedStart).toInt() + val endCol = ChronoUnit.DAYS.between(weekStart, clippedEnd).toInt() + return WeekBar(item, startCol, endCol - startCol + 1) +} + +/** Greedy interval packing: assign each bar to the first lane whose last-occupied column has passed. */ +fun packLanes(bars: List, maxLanes: Int): List> { + val lanes = mutableListOf>() + val laneEnd = mutableListOf() + for (bar in bars) { + var placed = false + for (i in lanes.indices) { + if (laneEnd[i] < bar.startCol) { + lanes[i].add(bar) + laneEnd[i] = bar.startCol + bar.span - 1 + placed = true + break + } + } + if (!placed && lanes.size < maxLanes) { + lanes.add(mutableListOf(bar)) + laneEnd.add(bar.startCol + bar.span - 1) + } + // If every lane is full, this bar is silently dropped from the grid for that week - + // it's still visible from the day-agenda list below, just not as a chip up top. + } + return lanes +} + +/** Bars + packed lanes for a single week, ready to render. */ +fun lanesForWeek(week: List, itemsByDay: Map>, maxLanes: Int = MAX_LANES_PER_WEEK): List> { + val itemsThisWeek = week.flatMap { itemsByDay[it].orEmpty() }.distinctBy { it.href.ifEmpty { it.uid } } + val bars = itemsThisWeek.mapNotNull { barFor(it, week) } + .sortedWith(compareBy({ it.startCol }, { -it.span })) + return packLanes(bars, maxLanes) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/PriorityColors.kt b/ncal/app/src/main/java/com/homelab/ncal/util/PriorityColors.kt new file mode 100644 index 0000000..181e19b --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/PriorityColors.kt @@ -0,0 +1,21 @@ +package com.homelab.ncal.util + +import androidx.compose.ui.graphics.Color + +/** + * iCal PRIORITY (1-9) color convention specified for NCal: 1-4 is high/red, 5 is medium/yellow, + * 6-9 is low/blue. 0 (undefined) has no color. + */ +fun priorityColor(priority: Int): Color? = when { + priority in 1..4 -> Color(0xFFE53935) + priority == 5 -> Color(0xFFF9A825) + priority in 6..9 -> Color(0xFF1E88E5) + else -> null +} + +fun priorityLabel(priority: Int): String = when { + priority == 0 -> "None" + priority in 1..4 -> "$priority – High" + priority == 5 -> "5 – Medium" + else -> "$priority – Low" +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt b/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt new file mode 100644 index 0000000..4030d9f --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt @@ -0,0 +1,67 @@ +package com.homelab.ncal.util + +import biweekly.util.Frequency +import biweekly.util.Recurrence +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * Minimal RRULE evaluator. CalDAV servers return the *master* VEVENT/VTODO for a + * recurring item (with its original, possibly long-past, DTSTART) - they don't expand + * occurrences for us in the calendar-data payload. Without this, a weekly meeting or a + * yearly birthday would show once, forever, on the date it was first created. + * + * This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT, + * and UNTIL. It intentionally does not implement BYDAY/BYMONTH/BYSETPOS/etc - those cover + * a small minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday"). + * Anything using those rule parts still round-trips correctly on save/edit (the raw ICS + * is preserved via [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be + * *re-dated* for display, and will show at its original occurrence like before. + */ +object RecurrenceUtils { + + private const val MAX_ITERATIONS = 10_000 + + /** + * Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis], + * or null if the series has already ended (past UNTIL/COUNT) or uses an unsupported rule. + */ + fun nextOccurrence(originalStartMillis: Long, recurrence: Recurrence, referenceMillis: Long): Long? { + val zone = ZoneId.systemDefault() + val freq = recurrence.frequency ?: return null + if (freq !in SUPPORTED_FREQUENCIES) return originalStartMillis // fall back to showing the master as-is + + val interval = (recurrence.interval ?: 1).coerceAtLeast(1) + val until: ZonedDateTime? = recurrence.until?.let { + Instant.ofEpochMilli(it.time).atZone(zone) + } + val maxCount = recurrence.count + + var current = Instant.ofEpochMilli(originalStartMillis).atZone(zone) + val reference = Instant.ofEpochMilli(referenceMillis).atZone(zone) + + if (!current.isBefore(reference)) return current.toInstant().toEpochMilli() + + var occurrenceIndex = 1 + var iterations = 0 + while (current.isBefore(reference)) { + if (iterations++ > MAX_ITERATIONS) return null + + current = when (freq) { + Frequency.DAILY -> current.plusDays(interval.toLong()) + Frequency.WEEKLY -> current.plusWeeks(interval.toLong()) + Frequency.MONTHLY -> current.plusMonths(interval.toLong()) + Frequency.YEARLY -> current.plusYears(interval.toLong()) + else -> return null + } + occurrenceIndex++ + + if (maxCount != null && occurrenceIndex > maxCount) return null + if (until != null && current.isAfter(until)) return null + } + return current.toInstant().toEpochMilli() + } + + private val SUPPORTED_FREQUENCIES = setOf(Frequency.DAILY, Frequency.WEEKLY, Frequency.MONTHLY, Frequency.YEARLY) +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/EncryptedWidgetState.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/EncryptedWidgetState.kt new file mode 100644 index 0000000..d5d4782 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/EncryptedWidgetState.kt @@ -0,0 +1,79 @@ +package com.homelab.ncal.widget + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.Serializer +import androidx.datastore.dataStoreFile +import androidx.glance.state.GlanceStateDefinition +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private const val KEYSTORE_ALIAS = "ncal_widget_state_key" +private const val GCM_IV_LENGTH = 12 +private const val GCM_TAG_LENGTH_BITS = 128 + +private fun widgetStateKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEYSTORE_ALIAS, null) as? SecretKey)?.let { return it } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + ) + return generator.generateKey() +} + +/** + * Encrypts the widget's persisted state (currently just "which month is this instance + * showing") at rest via an Android Keystore-backed AES key. The value itself is trivial - + * an integer month offset, no calendar content - but the goal is nothing the app writes to + * disk is left in plaintext, so it gets the same treatment as everything else. + */ +private object EncryptedIntSerializer : Serializer { + override val defaultValue: Int = 0 + + override suspend fun readFrom(input: InputStream): Int { + val bytes = input.readBytes() + if (bytes.isEmpty()) return defaultValue + return try { + val iv = bytes.copyOfRange(0, GCM_IV_LENGTH) + val cipherText = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, widgetStateKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + String(cipher.doFinal(cipherText)).toInt() + } catch (e: Exception) { + throw CorruptionException("Could not decrypt widget state", e) + } + } + + override suspend fun writeTo(t: Int, output: OutputStream) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, widgetStateKey()) + val cipherText = cipher.doFinal(t.toString().toByteArray()) + output.write(cipher.iv) + output.write(cipherText) + } +} + +object EncryptedMonthOffsetStateDefinition : GlanceStateDefinition { + private const val FILE_PREFIX = "widget_month_offset_" + + override suspend fun getDataStore(context: Context, fileKey: String): DataStore = + DataStoreFactory.create(serializer = EncryptedIntSerializer) { getLocation(context, fileKey) } + + override fun getLocation(context: Context, fileKey: String): File = + context.dataStoreFile("$FILE_PREFIX$fileKey") +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidget.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidget.kt new file mode 100644 index 0000000..baa9309 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidget.kt @@ -0,0 +1,324 @@ +package com.homelab.ncal.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextDecoration +import androidx.glance.text.TextStyle +import com.homelab.ncal.MainActivity +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.data.model.ItemType +import com.homelab.ncal.util.groupByAllOccupiedDays +import com.homelab.ncal.util.lanesForWeek +import com.homelab.ncal.util.priorityColor +import com.homelab.ncal.util.weeksForMonth +import kotlinx.coroutines.flow.first +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.TextStyle as JavaTextStyle +import java.time.temporal.WeekFields +import java.util.Locale + +/** + * Font/element sizes and how many event/task lanes to show per week, derived from the widget's + * current on-screen size so a resized-larger widget actually uses the extra space instead of + * showing the same small grid surrounded by blank margin. + */ +private data class MonthWidgetScale( + val headerFontSize: TextUnit, + val weekdayFontSize: TextUnit, + val dayFontSize: TextUnit, + val dayCircleSize: Dp, + val itemFontSize: TextUnit, + val laneHeight: Dp, + val maxLanes: Int +) + +private fun monthWidgetScaleFor(size: DpSize): MonthWidgetScale { + val compact = size.width < 280.dp + val expanded = size.width >= 380.dp + val maxLanes = when { + size.height < 280.dp -> 2 + size.height >= 380.dp -> 4 + else -> 3 + } + return when { + compact -> MonthWidgetScale(14.sp, 10.sp, 11.sp, 20.dp, 10.sp, 16.dp, maxLanes) + expanded -> MonthWidgetScale(18.sp, 13.sp, 15.sp, 28.dp, 13.sp, 20.dp, maxLanes) + else -> MonthWidgetScale(16.sp, 11.sp, 13.sp, 24.dp, 11.sp, 18.dp, maxLanes) + } +} + +/** + * Home screen widget mirroring the in-app Month screen: a one-month grid with event pills + * and task dots visible on each day. Reuses the exact same [weeksForMonth]/[lanesForWeek] + * layout algorithm as [com.homelab.ncal.ui.month.MonthScreen] so the two stay in sync. + * + * Glance's Row/Column only support equal-weight distribution (no proportional weights like + * Compose's `Modifier.weight(n)`), so multi-day event bars are sized with explicit `Dp` widths + * computed from [LocalSize] instead. + */ +class MonthGridWidget : GlanceAppWidget() { + override val stateDefinition = EncryptedMonthOffsetStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val offset = getAppWidgetState(context, EncryptedMonthOffsetStateDefinition, id) + val month = YearMonth.now().plusMonths(offset.toLong()) + + val repo = (context.applicationContext as NcalApplication).repository + val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url } + val itemsByDay = repo.observeItems(urls).first().groupByAllOccupiedDays(ZoneId.systemDefault()) + + provideContent { + MonthWidgetContent(month = month, itemsByDay = itemsByDay) + } + } +} + +@Composable +private fun MonthWidgetContent(month: YearMonth, itemsByDay: Map>) { + val context = LocalContext.current + val scale = monthWidgetScaleFor(LocalSize.current) + GlanceTheme { + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.background) + .clickable(actionStartActivity(Intent(context, MainActivity::class.java))) + .padding(8.dp) + ) { + WidgetHeader(month, scale) + WidgetWeekdayLabels(scale) + WidgetMonthGrid(month, itemsByDay, scale) + } + } +} + +@Composable +private fun WidgetHeader(month: YearMonth, scale: MonthWidgetScale) { + Row( + modifier = GlanceModifier.fillMaxWidth().padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "‹", + modifier = GlanceModifier.clickable(actionRunCallback()).padding(8.dp), + style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = scale.headerFontSize) + ) + Text( + "${month.month.getDisplayName(JavaTextStyle.FULL, Locale.getDefault())} ${month.year}", + modifier = GlanceModifier.defaultWeight(), + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + fontSize = scale.headerFontSize, + textAlign = TextAlign.Center + ) + ) + Text( + "›", + modifier = GlanceModifier.clickable(actionRunCallback()).padding(8.dp), + style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = scale.headerFontSize) + ) + } +} + +@Composable +private fun WidgetWeekdayLabels(scale: MonthWidgetScale) { + val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val days = (0..6).map { firstDow.plus(it.toLong()) } + Row(modifier = GlanceModifier.fillMaxWidth()) { + days.forEach { dow -> + Box(modifier = GlanceModifier.defaultWeight(), contentAlignment = Alignment.Center) { + Text( + dow.getDisplayName(JavaTextStyle.SHORT, Locale.getDefault()), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = scale.weekdayFontSize) + ) + } + } + } +} + +@Composable +private fun WidgetMonthGrid(month: YearMonth, itemsByDay: Map>, scale: MonthWidgetScale) { + val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val weeks = weeksForMonth(month, firstDow) + val today = LocalDate.now() + // Available width = widget width minus the outer 8.dp+8.dp padding applied in MonthWidgetContent. + val columnWidth = (LocalSize.current.width - 16.dp) / 7 + + Column(modifier = GlanceModifier.fillMaxSize()) { + // defaultWeight() requires a ColumnScope receiver, only available here (inside Column's + // own content lambda) - computed here and passed down rather than built inside + // WidgetWeekRow, which is a separate composable with no ColumnScope in lexical scope. + weeks.forEach { week -> + WidgetWeekRow(week, month, itemsByDay, today, columnWidth, scale, GlanceModifier.fillMaxWidth().defaultWeight()) + } + } +} + +@Composable +private fun WidgetWeekRow( + week: List, + month: YearMonth, + itemsByDay: Map>, + today: LocalDate, + columnWidth: Dp, + scale: MonthWidgetScale, + modifier: GlanceModifier +) { + val lanes = lanesForWeek(week, itemsByDay, maxLanes = scale.maxLanes) + val context = LocalContext.current + + // Each week claims an equal share of any extra vertical room a resized-larger widget has - + // without this, leftover space collects as one dead gap below the last week instead of + // extra breathing room between weeks. + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 2.dp)) { + Row(modifier = GlanceModifier.fillMaxWidth()) { + week.forEach { date -> + val inMonth = YearMonth.from(date) == month + val isToday = date == today + Box( + modifier = GlanceModifier + .defaultWeight() + .clickable(actionStartActivity(Intent(context, MainActivity::class.java))), + contentAlignment = Alignment.Center + ) { + var circle = GlanceModifier.size(scale.dayCircleSize).cornerRadius(scale.dayCircleSize / 2) + if (isToday) circle = circle.background(GlanceTheme.colors.primaryContainer) + Box(modifier = circle, contentAlignment = Alignment.Center) { + Text( + date.dayOfMonth.toString(), + style = TextStyle( + color = if (!inMonth) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface, + fontSize = scale.dayFontSize, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal + ) + ) + } + } + } + } + lanes.forEach { lane -> + Row(modifier = GlanceModifier.fillMaxWidth().padding(vertical = 1.dp)) { + var col = 0 + while (col < 7) { + val bar = lane.firstOrNull { it.startCol == col } + if (bar != null) { + WidgetItemBar(item = bar.item, width = columnWidth * bar.span, scale = scale) + col += bar.span + } else { + Spacer(modifier = GlanceModifier.width(columnWidth).height(scale.laneHeight)) + col += 1 + } + } + } + } + } + } +} + +/** Mirrors MonthScreen's GridItemRow: events are a solid pill, tasks are a dot + text. */ +@Composable +private fun WidgetItemBar(item: CalendarItem, width: Dp, scale: MonthWidgetScale) { + val context = LocalContext.current + val intent = Intent(context, MainActivity::class.java).apply { + putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name) + putExtra(MainActivity.EXTRA_ITEM_HREF, item.href) + } + + if (item.type == ItemType.TASK) { + // A Row's defaultWeight() doesn't reliably bound the Text's width inside a fixed-width + // Row under Glance/RemoteViews, so stack dot+text in a Box (with fillMaxWidth on the + // Text, reserving left padding for the dot) instead of laying them out side by side. + // A `background` is also required for Glance/RemoteViews to actually enforce the exact + // width below (without one, width() is silently ignored and content overflows) - use the + // same color as the page background so it stays visually invisible, matching the app. + Box( + modifier = GlanceModifier + .width(width) + .height(scale.laneHeight) + .background(GlanceTheme.colors.background) + .clickable(actionStartActivity(intent)) + .padding(horizontal = 2.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + item.summary, + modifier = GlanceModifier.fillMaxWidth().padding(start = if (!item.completed) 8.dp else 0.dp), + maxLines = 1, + style = TextStyle( + color = if (item.completed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface, + fontSize = scale.itemFontSize, + textDecoration = if (item.completed) TextDecoration.LineThrough else TextDecoration.None + ) + ) + if (!item.completed) { + val dotColor = priorityColor(item.priority) + var dot = GlanceModifier.size(5.dp).cornerRadius(3.dp) + dot = if (dotColor != null) dot.background(dotColor) else dot.background(GlanceTheme.colors.tertiary) + Box(modifier = dot) {} + } + } + } else { + Box( + modifier = GlanceModifier + .width(width) + .height(scale.laneHeight) + .cornerRadius(4.dp) + .background(GlanceTheme.colors.primary) + .clickable(actionStartActivity(intent)) + .padding(horizontal = 3.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + item.summary, + modifier = GlanceModifier.fillMaxWidth(), + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onPrimary, + fontSize = scale.itemFontSize, + textDecoration = if (item.completed) TextDecoration.LineThrough else TextDecoration.None + ) + ) + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidgetReceiver.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidgetReceiver.kt new file mode 100644 index 0000000..2e4b49e --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/MonthGridWidgetReceiver.kt @@ -0,0 +1,8 @@ +package com.homelab.ncal.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class MonthGridWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = MonthGridWidget() +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/MonthWidgetActions.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/MonthWidgetActions.kt new file mode 100644 index 0000000..3ed3293 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/MonthWidgetActions.kt @@ -0,0 +1,22 @@ +package com.homelab.ncal.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.appwidget.updateAll + +class PrevMonthAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + updateAppWidgetState(context, EncryptedMonthOffsetStateDefinition, glanceId) { offset -> offset - 1 } + MonthGridWidget().updateAll(context) + } +} + +class NextMonthAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + updateAppWidgetState(context, EncryptedMonthOffsetStateDefinition, glanceId) { offset -> offset + 1 } + MonthGridWidget().updateAll(context) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt new file mode 100644 index 0000000..ddedeec --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt @@ -0,0 +1,165 @@ +package com.homelab.ncal.widget + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.homelab.ncal.MainActivity +import com.homelab.ncal.NcalApplication +import com.homelab.ncal.data.model.CalendarItem +import com.homelab.ncal.util.priorityColor +import kotlinx.coroutines.flow.first +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +internal val taskHrefKey = ActionParameters.Key("task_href") + +/** Home screen widget listing incomplete tasks, grouped and ordered by due date - the widget + * equivalent of the in-app Tasks list, minus the subtask tree (kept flat/chronological here + * since the point of a widget is a quick "what's next" glance, not full hierarchy browsing). */ +class NextTasksWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = (context.applicationContext as NcalApplication).repository + val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url } + val zone = ZoneId.systemDefault() + + val groups = repo.observeTasks(urls).first() + .filter { !it.completed } + .sortedWith(compareBy({ it.due == null }, { it.due })) + .groupBy { it.due?.let { d -> Instant.ofEpochMilli(d).atZone(zone).toLocalDate() } } + .toList() + .sortedWith(compareBy(nullsLast()) { it.first }) + + provideContent { + NextTasksContent(groups) + } + } +} + +@Composable +private fun NextTasksContent(groups: List>>) { + val context = LocalContext.current + GlanceTheme { + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.background) + .clickable(actionStartActivity(Intent(context, MainActivity::class.java))) + .padding(8.dp) + ) { + Text( + "Tasks", + style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp), + modifier = GlanceModifier.padding(bottom = 4.dp) + ) + if (groups.isEmpty()) { + Text( + "Nothing due", + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp) + ) + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { + groups.forEach { (date, tasksForDate) -> + item(itemId = date?.toEpochDay() ?: -1L) { + Text( + text = date?.let { + it.format(DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault())) + } ?: "No due date", + style = TextStyle( + color = GlanceTheme.colors.primary, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ), + modifier = GlanceModifier.padding(top = 6.dp, bottom = 2.dp) + ) + } + items(tasksForDate, itemId = { it.href.hashCode().toLong() }) { task -> + TaskRow(task) + } + } + } + } + } + } +} + +@Composable +private fun TaskRow(item: CalendarItem) { + val context = LocalContext.current + val intent = Intent(context, MainActivity::class.java).apply { + putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name) + putExtra(MainActivity.EXTRA_ITEM_HREF, item.href) + } + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .background(GlanceTheme.colors.background) + .clickable(actionStartActivity(intent)) + .padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = GlanceModifier + .size(16.dp) + .clickable(actionRunCallback(actionParametersOf(taskHrefKey to item.href))), + contentAlignment = Alignment.Center + ) { + val dotColor = priorityColor(item.priority) + var dot = GlanceModifier.size(10.dp).cornerRadius(5.dp) + dot = if (dotColor != null) dot.background(dotColor) else dot.background(GlanceTheme.colors.tertiary) + Box(modifier = dot) {} + } + Spacer(modifier = GlanceModifier.width(6.dp)) + Text( + item.summary, + modifier = GlanceModifier.defaultWeight().background(GlanceTheme.colors.background), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp) + ) + item.due?.let { due -> + Spacer(modifier = GlanceModifier.width(4.dp)) + Text( + formatTime(due), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp) + ) + } + } +} + +private fun formatTime(millis: Long): String = + Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("h:mm a", Locale.getDefault())) diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetActions.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetActions.kt new file mode 100644 index 0000000..4d1210f --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetActions.kt @@ -0,0 +1,18 @@ +package com.homelab.ncal.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import com.homelab.ncal.NcalApplication + +/** [com.homelab.ncal.data.repository.NextcloudRepository.save] already triggers a widget + * refresh for every write, so toggling completion here doesn't need its own updateAll call. */ +class ToggleTaskAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + val href = parameters[taskHrefKey] ?: return + val repo = (context.applicationContext as NcalApplication).repository + val item = repo.itemByHref(href) ?: return + repo.toggleTaskComplete(item) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetReceiver.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetReceiver.kt new file mode 100644 index 0000000..f17330f --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidgetReceiver.kt @@ -0,0 +1,8 @@ +package com.homelab.ncal.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class NextTasksWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = NextTasksWidget() +} diff --git a/ncal/app/src/main/res/drawable/ic_launcher_foreground.xml b/ncal/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..6e1fee5 --- /dev/null +++ b/ncal/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + diff --git a/ncal/app/src/main/res/layout/glance_default_loading_layout.xml b/ncal/app/src/main/res/layout/glance_default_loading_layout.xml new file mode 100644 index 0000000..b98b5b4 --- /dev/null +++ b/ncal/app/src/main/res/layout/glance_default_loading_layout.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/ncal/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ncal/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/ncal/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/ncal/app/src/main/res/values/colors.xml b/ncal/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5732001 --- /dev/null +++ b/ncal/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #1F6FEB + diff --git a/ncal/app/src/main/res/values/strings.xml b/ncal/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a28f303 --- /dev/null +++ b/ncal/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + NCal + Calendar Month + Shows the current month at a glance with your events. + Tasks + Shows your upcoming tasks. + diff --git a/ncal/app/src/main/res/values/themes.xml b/ncal/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..b9680f6 --- /dev/null +++ b/ncal/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + +