Repository: icabetong/fokus-android Branch: main Commit: fb57365edc3f Files: 335 Total size: 937.2 KB Directory structure: gitextract_ht_tjfbb/ ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.isaiahvonrundstedt.fokus.database.AppDatabase/ │ │ └── 8.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── isaiahvonrundstedt/ │ │ └── fokus/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── isaiahvonrundstedt/ │ │ │ └── fokus/ │ │ │ ├── Fokus.kt │ │ │ ├── components/ │ │ │ │ ├── custom/ │ │ │ │ │ ├── ItemDecoration.kt │ │ │ │ │ └── ItemSwipeCallback.kt │ │ │ │ ├── enums/ │ │ │ │ │ └── SortDirection.kt │ │ │ │ ├── extensions/ │ │ │ │ │ ├── android/ │ │ │ │ │ │ ├── AppCompatExtensions.kt │ │ │ │ │ │ ├── IntentExtensions.kt │ │ │ │ │ │ ├── TextViewExtensions.kt │ │ │ │ │ │ └── UriExtensions.kt │ │ │ │ │ └── jdk/ │ │ │ │ │ ├── CalendarExtensions.kt │ │ │ │ │ ├── ListExtensions.kt │ │ │ │ │ └── TimeExtensions.kt │ │ │ │ ├── interfaces/ │ │ │ │ │ ├── Streamable.kt │ │ │ │ │ └── Swipeable.kt │ │ │ │ ├── json/ │ │ │ │ │ ├── JsonDataStreamer.kt │ │ │ │ │ └── Metadata.kt │ │ │ │ ├── modules/ │ │ │ │ │ ├── DatabaseModule.kt │ │ │ │ │ ├── ExternalModule.kt │ │ │ │ │ └── InternalModule.kt │ │ │ │ ├── preference/ │ │ │ │ │ └── InformationHolder.kt │ │ │ │ ├── receiver/ │ │ │ │ │ └── LocalizationReceiver.kt │ │ │ │ ├── service/ │ │ │ │ │ ├── BackupRestoreService.kt │ │ │ │ │ ├── DataExporterService.kt │ │ │ │ │ ├── DataImporterService.kt │ │ │ │ │ ├── FileImporterService.kt │ │ │ │ │ └── NotificationActionService.kt │ │ │ │ ├── utils/ │ │ │ │ │ ├── DataArchiver.kt │ │ │ │ │ ├── NotificationChannelManager.kt │ │ │ │ │ ├── PermissionManager.kt │ │ │ │ │ └── PreferenceManager.kt │ │ │ │ └── views/ │ │ │ │ ├── RadioButtonCompat.kt │ │ │ │ ├── ReactiveTextColorSwitch.kt │ │ │ │ └── TwoLineRadioButton.kt │ │ │ ├── database/ │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── converter/ │ │ │ │ │ ├── ColorConverter.kt │ │ │ │ │ └── DateTimeConverter.kt │ │ │ │ ├── dao/ │ │ │ │ │ ├── AttachmentDAO.kt │ │ │ │ │ ├── EventDAO.kt │ │ │ │ │ ├── LogDAO.kt │ │ │ │ │ ├── ScheduleDAO.kt │ │ │ │ │ ├── SubjectDAO.kt │ │ │ │ │ └── TaskDAO.kt │ │ │ │ └── repository/ │ │ │ │ ├── EventRepository.kt │ │ │ │ ├── LogRepository.kt │ │ │ │ ├── SubjectRepository.kt │ │ │ │ └── TaskRepository.kt │ │ │ └── features/ │ │ │ ├── about/ │ │ │ │ ├── AboutFragment.kt │ │ │ │ ├── LibrariesFragment.kt │ │ │ │ └── NoticesFragment.kt │ │ │ ├── attachments/ │ │ │ │ ├── Attachment.kt │ │ │ │ ├── AttachmentOptionSheet.kt │ │ │ │ └── attach/ │ │ │ │ ├── AttachToTaskActivity.kt │ │ │ │ ├── AttachToTaskAdapter.kt │ │ │ │ └── AttachToTaskViewModel.kt │ │ │ ├── core/ │ │ │ │ ├── activities/ │ │ │ │ │ └── MainActivity.kt │ │ │ │ ├── fragment/ │ │ │ │ │ └── RootFragment.kt │ │ │ │ └── worker/ │ │ │ │ └── ActionWorker.kt │ │ │ ├── event/ │ │ │ │ ├── Event.kt │ │ │ │ ├── EventAdapter.kt │ │ │ │ ├── EventFragment.kt │ │ │ │ ├── EventPackage.kt │ │ │ │ ├── EventViewModel.kt │ │ │ │ ├── archived/ │ │ │ │ │ ├── ArchivedEventAdapter.kt │ │ │ │ │ ├── ArchivedEventFragment.kt │ │ │ │ │ └── ArchivedEventViewModel.kt │ │ │ │ ├── editor/ │ │ │ │ │ ├── EventEditorContainer.kt │ │ │ │ │ ├── EventEditorFragment.kt │ │ │ │ │ └── EventEditorViewModel.kt │ │ │ │ └── widget/ │ │ │ │ ├── EventWidgetProvider.kt │ │ │ │ ├── EventWidgetRemoteViewFactory.kt │ │ │ │ └── EventWidgetService.kt │ │ │ ├── log/ │ │ │ │ ├── Log.kt │ │ │ │ ├── LogAdapter.kt │ │ │ │ ├── LogFragment.kt │ │ │ │ └── LogViewModel.kt │ │ │ ├── notifications/ │ │ │ │ ├── NotificationWorker.kt │ │ │ │ ├── event/ │ │ │ │ │ ├── EventNotificationScheduler.kt │ │ │ │ │ └── EventNotificationWorker.kt │ │ │ │ ├── subject/ │ │ │ │ │ ├── ClassNotificationScheduler.kt │ │ │ │ │ └── ClassNotificationWorker.kt │ │ │ │ └── task/ │ │ │ │ ├── TaskNotificationScheduler.kt │ │ │ │ ├── TaskNotificationWorker.kt │ │ │ │ └── TaskReminderWorker.kt │ │ │ ├── schedule/ │ │ │ │ ├── Schedule.kt │ │ │ │ ├── ScheduleEditor.kt │ │ │ │ ├── picker/ │ │ │ │ │ ├── SchedulePickerAdapter.kt │ │ │ │ │ └── SchedulePickerSheet.kt │ │ │ │ └── viewer/ │ │ │ │ ├── ScheduleViewerAdapter.kt │ │ │ │ └── ScheduleViewerSheet.kt │ │ │ ├── settings/ │ │ │ │ ├── BackupFragment.kt │ │ │ │ └── SettingsFragment.kt │ │ │ ├── shared/ │ │ │ │ ├── abstracts/ │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ ├── BaseAdapter.kt │ │ │ │ │ ├── BaseBasicAdapter.kt │ │ │ │ │ ├── BaseBottomSheet.kt │ │ │ │ │ ├── BaseEditorFragment.kt │ │ │ │ │ ├── BaseFragment.kt │ │ │ │ │ ├── BasePickerFragment.kt │ │ │ │ │ ├── BasePreference.kt │ │ │ │ │ ├── BaseService.kt │ │ │ │ │ ├── BaseViewerFragment.kt │ │ │ │ │ └── BaseWorker.kt │ │ │ │ └── adapters/ │ │ │ │ └── MenuAdapter.kt │ │ │ ├── subject/ │ │ │ │ ├── Subject.kt │ │ │ │ ├── SubjectAdapter.kt │ │ │ │ ├── SubjectFragment.kt │ │ │ │ ├── SubjectPackage.kt │ │ │ │ ├── SubjectViewModel.kt │ │ │ │ ├── archived/ │ │ │ │ │ ├── ArchivedSubjectAdapter.kt │ │ │ │ │ ├── ArchivedSubjectFragment.kt │ │ │ │ │ └── ArchivedSubjectViewModel.kt │ │ │ │ ├── editor/ │ │ │ │ │ ├── SubjectEditorContainer.kt │ │ │ │ │ ├── SubjectEditorFragment.kt │ │ │ │ │ └── SubjectEditorViewModel.kt │ │ │ │ ├── picker/ │ │ │ │ │ ├── SubjectPickerAdapter.kt │ │ │ │ │ ├── SubjectPickerFragment.kt │ │ │ │ │ └── SubjectPickerViewModel.kt │ │ │ │ └── widget/ │ │ │ │ ├── SubjectWidgetProvider.kt │ │ │ │ ├── SubjectWidgetRemoteViewFactory.kt │ │ │ │ └── SubjectWidgetService.kt │ │ │ ├── task/ │ │ │ │ ├── Task.kt │ │ │ │ ├── TaskAdapter.kt │ │ │ │ ├── TaskFragment.kt │ │ │ │ ├── TaskPackage.kt │ │ │ │ ├── TaskViewModel.kt │ │ │ │ ├── archived/ │ │ │ │ │ ├── ArchivedTaskAdapter.kt │ │ │ │ │ ├── ArchivedTaskFragment.kt │ │ │ │ │ └── ArchivedTaskViewModel.kt │ │ │ │ ├── editor/ │ │ │ │ │ ├── TaskEditorContainer.kt │ │ │ │ │ ├── TaskEditorFragment.kt │ │ │ │ │ └── TaskEditorViewModel.kt │ │ │ │ └── widget/ │ │ │ │ ├── TaskWidgetProvider.kt │ │ │ │ ├── TaskWidgetRemoteViewFactory.kt │ │ │ │ └── TaskWidgetService.kt │ │ │ └── viewer/ │ │ │ └── ImageViewer.kt │ │ └── res/ │ │ ├── anim/ │ │ │ ├── anim_fade_in.xml │ │ │ ├── anim_slide_down.xml │ │ │ └── anim_slide_up.xml │ │ ├── color/ │ │ │ ├── color_text_input_stroke.xml │ │ │ ├── selector_chip_background.xml │ │ │ ├── selector_chip_stroke_color.xml │ │ │ └── selector_chip_text_color.xml │ │ ├── drawable/ │ │ │ ├── ic_hero_sort_ascending_24.xml │ │ │ ├── ic_hero_sort_descending_24.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_launcher_monochrome.xml │ │ │ ├── ic_outline_access_time_24.xml │ │ │ ├── ic_outline_add_24.xml │ │ │ ├── ic_outline_archive_24.xml │ │ │ ├── ic_outline_arrow_back_24.xml │ │ │ ├── ic_outline_attach_file_24.xml │ │ │ ├── ic_outline_balance_24.xml │ │ │ ├── ic_outline_calendar_month_24.xml │ │ │ ├── ic_outline_celebration_24.xml │ │ │ ├── ic_outline_check_24.xml │ │ │ ├── ic_outline_checklist_24.xml │ │ │ ├── ic_outline_close_24.xml │ │ │ ├── ic_outline_code_24.xml │ │ │ ├── ic_outline_color_lens_24.xml │ │ │ ├── ic_outline_confirmation_number_24.xml │ │ │ ├── ic_outline_date_range_24.xml │ │ │ ├── ic_outline_delete_24.xml │ │ │ ├── ic_outline_edit_note_24.xml │ │ │ ├── ic_outline_event_24.xml │ │ │ ├── ic_outline_event_busy_24.xml │ │ │ ├── ic_outline_event_repeat_24.xml │ │ │ ├── ic_outline_file_download_24.xml │ │ │ ├── ic_outline_file_open_24.xml │ │ │ ├── ic_outline_file_upload_24.xml │ │ │ ├── ic_outline_filter_alt_24.xml │ │ │ ├── ic_outline_info_24.xml │ │ │ ├── ic_outline_lightbulb_24.xml │ │ │ ├── ic_outline_link_24.xml │ │ │ ├── ic_outline_location_on_24.xml │ │ │ ├── ic_outline_menu_24.xml │ │ │ ├── ic_outline_more_vert_24.xml │ │ │ ├── ic_outline_music_note_24.xml │ │ │ ├── ic_outline_notes_24.xml │ │ │ ├── ic_outline_notifications_active_24.xml │ │ │ ├── ic_outline_numbers_24.xml │ │ │ ├── ic_outline_open_in_new_24.xml │ │ │ ├── ic_outline_person_2_24.xml │ │ │ ├── ic_outline_priority_high_24.xml │ │ │ ├── ic_outline_query_stats_24.xml │ │ │ ├── ic_outline_save_24.xml │ │ │ ├── ic_outline_science_24.xml │ │ │ ├── ic_outline_sensor_door_24.xml │ │ │ ├── ic_outline_settings_24.xml │ │ │ ├── ic_outline_share_24.xml │ │ │ ├── ic_outline_translate_24.xml │ │ │ ├── ic_outline_verified_24.xml │ │ │ ├── ic_outline_wb_sunny_24.xml │ │ │ ├── selector_checkbox.xml │ │ │ ├── shape_bottom_sheet.xml │ │ │ ├── shape_calendar_current_day.xml │ │ │ ├── shape_calendar_selected_day.xml │ │ │ ├── shape_cascade_background.xml │ │ │ ├── shape_color_holder.xml │ │ │ ├── shape_color_holder_chip.xml │ │ │ ├── shape_color_holder_vertical.xml │ │ │ ├── shape_icon_background.xml │ │ │ ├── shape_widget_background.xml │ │ │ ├── shortcut_icon_base_event.xml │ │ │ ├── shortcut_icon_base_subject.xml │ │ │ ├── shortcut_icon_base_task.xml │ │ │ ├── shortcut_icon_event.xml │ │ │ ├── shortcut_icon_subject.xml │ │ │ ├── shortcut_icon_task.xml │ │ │ ├── toggle_checked_to_unchecked.xml │ │ │ ├── toggle_unchecked_to_checked.xml │ │ │ ├── vector_checked.xml │ │ │ └── vector_unchecked.xml │ │ ├── layout/ │ │ │ ├── activity_attach_to_task.xml │ │ │ ├── activity_container_event.xml │ │ │ ├── activity_container_subject.xml │ │ │ ├── activity_container_task.xml │ │ │ ├── activity_main.xml │ │ │ ├── fragment_about.xml │ │ │ ├── fragment_archived_event.xml │ │ │ ├── fragment_archived_subject.xml │ │ │ ├── fragment_archived_task.xml │ │ │ ├── fragment_backup.xml │ │ │ ├── fragment_editor_event.xml │ │ │ ├── fragment_editor_subject.xml │ │ │ ├── fragment_editor_task.xml │ │ │ ├── fragment_event.xml │ │ │ ├── fragment_libraries.xml │ │ │ ├── fragment_logs.xml │ │ │ ├── fragment_notices.xml │ │ │ ├── fragment_picker_subject.xml │ │ │ ├── fragment_root.xml │ │ │ ├── fragment_settings.xml │ │ │ ├── fragment_subject.xml │ │ │ ├── fragment_task.xml │ │ │ ├── layout_appbar.xml │ │ │ ├── layout_appbar_editor.xml │ │ │ ├── layout_appbar_viewer.xml │ │ │ ├── layout_calendar_day.xml │ │ │ ├── layout_calendar_week_days.xml │ │ │ ├── layout_dialog_input_attachment.xml │ │ │ ├── layout_item_add.xml │ │ │ ├── layout_item_archived_event.xml │ │ │ ├── layout_item_archived_subject.xml │ │ │ ├── layout_item_archived_task.xml │ │ │ ├── layout_item_event.xml │ │ │ ├── layout_item_library.xml │ │ │ ├── layout_item_log.xml │ │ │ ├── layout_item_menu.xml │ │ │ ├── layout_item_schedule.xml │ │ │ ├── layout_item_subject.xml │ │ │ ├── layout_item_subject_picker.xml │ │ │ ├── layout_item_subject_single.xml │ │ │ ├── layout_item_task.xml │ │ │ ├── layout_item_task_send.xml │ │ │ ├── layout_item_widget.xml │ │ │ ├── layout_navigation_header.xml │ │ │ ├── layout_preference_info.xml │ │ │ ├── layout_sheet_options.xml │ │ │ ├── layout_sheet_schedule.xml │ │ │ ├── layout_sheet_schedule_editor.xml │ │ │ ├── layout_viewer_image.xml │ │ │ ├── layout_widget_events.xml │ │ │ ├── layout_widget_progress.xml │ │ │ ├── layout_widget_subjects.xml │ │ │ └── layout_widget_tasks.xml │ │ ├── menu/ │ │ │ ├── menu_add.xml │ │ │ ├── menu_attachment.xml │ │ │ ├── menu_editor.xml │ │ │ ├── menu_events.xml │ │ │ ├── menu_logs.xml │ │ │ ├── menu_share.xml │ │ │ ├── menu_subjects.xml │ │ │ ├── menu_tasks.xml │ │ │ └── navigation_main.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── navigation/ │ │ │ ├── navigation_container_event.xml │ │ │ ├── navigation_container_subject.xml │ │ │ ├── navigation_container_task.xml │ │ │ ├── navigation_main.xml │ │ │ └── navigation_root.xml │ │ ├── values/ │ │ │ ├── array.xml │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimen.xml │ │ │ ├── integer.xml │ │ │ ├── strings.xml │ │ │ ├── themes.xml │ │ │ └── values.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-hdpi/ │ │ │ └── dimen.xml │ │ ├── values-id/ │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ ├── values-night-v23/ │ │ │ └── themes.xml │ │ ├── values-night-v27/ │ │ │ └── themes.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-v23/ │ │ │ └── themes.xml │ │ ├── values-v27/ │ │ │ └── themes.xml │ │ └── xml/ │ │ ├── xml_about_main.xml │ │ ├── xml_about_notices.xml │ │ ├── xml_launcher_shortcuts.xml │ │ ├── xml_provider_paths.xml │ │ ├── xml_settings_backups.xml │ │ ├── xml_settings_main.xml │ │ ├── xml_widget_events.xml │ │ ├── xml_widget_subjects.xml │ │ └── xml_widget_tasks.xml │ └── test/ │ └── java/ │ └── com/ │ └── isaiahvonrundstedt/ │ └── fokus/ │ ├── ExampleUnitTest.kt │ └── features/ │ ├── attachments/ │ │ └── AttachmentTests.kt │ ├── event/ │ │ └── EventUnitTest.kt │ ├── schedule/ │ │ └── ScheduleTests.kt │ └── task/ │ └── TaskUnitTest.kt ├── build.gradle ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── de/ │ │ └── short_description.txt │ └── en-US/ │ ├── full_description.txt │ └── short_description.txt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/* /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/tasks.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml /app/build/ /app/release/ .DS_Store /build /captures .externalNativeBuild .cxx ================================================ FILE: LICENSE ================================================ Copyright 2023, Isaiah Collins Abetong Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Fokus - To Do app tailored specifically for students [![License](https://img.shields.io/github/license/icabetong/fokus-android)](https://www.gnu.org/licenses/gpl-3.0.en.html) ![Issues](https://img.shields.io/github/issues/icabetong/fokus-android) ![PRs](https://img.shields.io/github/issues-pr/icabetong/fokus-android) Fokus is an open source application that combines a todo list and a calendar that can help you manage your school related work and events in one place. It's fast and beautiful yet simple design that can help you focus on what matters most. ### Not maintained anymore. I cannot maintain this project anymore due to my full time work. If you want to continue it's development, you can fork this repository and continue maintaining the application. Thank you for using my simple application. ## Features * Get reminded when a task is nearing its due * Get reminded about incoming events * Add attachments to your tasks * Persistent notifications for important tasks or events * No ads or any tracking * Open Source Code * On-Device Database ## Screenshots *Our preview mockups were created using 'Previewed' at https://previewed.app* ## Translations * 🇮🇶 Arabic (Thanks! Mustafa K. Mirza) * 🇺🇸 English * 🇩🇪 German (Thanks! [mschmidm](https://github.com/mschmidm)) * 🇫🇷 French (Thanks! David Simon) * 🇪🇸 Spanish (Thanks! Emmanuel Kunst) * 🇷🇺 Russian * 🇮🇩 Indonesian (Thanks! [Ilham Syahid S](https://github.com/ilhamsyahids)) * 🇹🇷 Turkish (Thanks! [Ahmet YÜREKLİ](https://github.com/vedfi)) ## Built with * Kotlin * Room * AndroidX * Other cool open-source libraries (see Acknowledgements) ## Versioning We use [SemVer](http://www.semver.org) for versioning. For the versions available, see the [tags on this repository](https://github.com/asayah-san/fokus-android/tags) ## Licenses This project is licensed under the GPL-3.0 - see the license file for more details ## Contributing This is an open-source personal project and I am very happy to accept community contributions. Open a PR to get started. ## Acknowledgements ### Libraries used * [Material Dialogs](https://github.com/afollestad/material-dialogs) * [Konfetti](https://github.com/DanielMartinus/Konfetti) * [ExpandableBottomBar](https://github.com/st235/ExpandableBottomBar) * [CommonsIO](https://commons.apache.org/proper/commons-io/) * [Moshi](https://github.com/square/moshi) * [Okio](https://github.com/square/okio) * [CalendarView](https://github.com/kizitonwose/CalendarView) * [AboutLibraries](https://github.com/mikepenz/AboutLibraries) * [Cascade](https://github.com/saket/cascade) ### Other materials used * [HeroIcons](https://www.heroicons.dev) - Tailwind Labs * [Launcher Icon](https://www.flaticon.com/authors/freepik) - Freepik via Flaticon * [Notification Sound](https://www.zapsplat.com/music/ui-alert-prompt-warm-wooden-mallet-style-notification-tone-generic-11/) - Zapsplat ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' id 'kotlin-parcelize' id 'com.mikepenz.aboutlibraries.plugin' id 'dagger.hilt.android.plugin' } android { compileSdkVersion 33 buildToolsVersion "33.0.0" defaultConfig { applicationId "com.isaiahvonrundstedt.fokus" minSdkVersion 21 targetSdkVersion 33 versionCode 19 versionName "2.5.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { viewBinding true } composeOptions { kotlinCompilerExtensionVersion = "1.1.0-beta02" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8 } kapt { correctErrorTypes true } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' implementation 'com.google.android.material:material:1.6.1' implementation 'com.afollestad.material-dialogs:core:3.3.0' implementation 'com.afollestad.material-dialogs:color:3.3.0' implementation 'com.afollestad.material-dialogs:datetime:3.3.0' implementation 'com.afollestad.material-dialogs:lifecycle:3.3.0' implementation 'com.github.kizitonwose:CalendarView:1.0.3' implementation 'nl.dionsegijn:konfetti-xml:2.0.2' implementation 'commons-io:commons-io:2.7' implementation 'com.mikepenz:aboutlibraries-core:8.9.4' implementation 'me.saket.cascade:cascade:1.3.0' implementation 'io.coil-kt:coil:1.2.2' implementation 'com.squareup.okio:okio:2.10.0' implementation 'com.github.chrisbanes:PhotoView:2.3.0' implementation 'com.google.accompanist:accompanist-insets:0.20.2' implementation 'androidx.appcompat:appcompat:1.4.2' implementation 'androidx.browser:browser:1.4.0' implementation 'androidx.fragment:fragment-ktx:1.5.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.5.0' implementation 'androidx.navigation:navigation-ui-ktx:2.5.0' implementation "androidx.navigation:navigation-compose:2.5.0" implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.transition:transition:1.4.1' implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.0' kapt 'androidx.lifecycle:lifecycle-common-java8:2.5.0' implementation 'androidx.room:room-runtime:2.4.2' implementation 'androidx.room:room-ktx:2.4.2' kapt 'androidx.room:room-compiler:2.4.2' implementation 'androidx.hilt:hilt-navigation-compose:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0' implementation 'com.google.dagger:hilt-android:2.42' kapt 'com.google.dagger:hilt-android-compiler:2.42' implementation 'com.squareup.moshi:moshi:1.13.0' implementation 'com.squareup.moshi:moshi-kotlin:1.13.0' kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.13.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -keep class *.R -keep class **.R$* { ; } ================================================ FILE: app/schemas/com.isaiahvonrundstedt.fokus.database.AppDatabase/8.json ================================================ { "formatVersion": 1, "database": { "version": 8, "identityHash": "a5a325b51a216bf9c8987bdd216bf28b", "entities": [ { "tableName": "subjects", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subjectID` TEXT NOT NULL, `code` TEXT, `description` TEXT, `instructor` TEXT, `tag` INTEGER NOT NULL, `isSubjectArchived` INTEGER NOT NULL, PRIMARY KEY(`subjectID`))", "fields": [ { "fieldPath": "subjectID", "columnName": "subjectID", "affinity": "TEXT", "notNull": true }, { "fieldPath": "code", "columnName": "code", "affinity": "TEXT", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "instructor", "columnName": "instructor", "affinity": "TEXT", "notNull": false }, { "fieldPath": "tag", "columnName": "tag", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isSubjectArchived", "columnName": "isSubjectArchived", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "subjectID" ], "autoGenerate": false }, "indices": [ { "name": "index_subjects_subjectID", "unique": false, "columnNames": [ "subjectID" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_subjects_subjectID` ON `${TABLE_NAME}` (`subjectID`)" } ], "foreignKeys": [] }, { "tableName": "tasks", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskID` TEXT NOT NULL, `name` TEXT, `notes` TEXT, `subject` TEXT, `isImportant` INTEGER NOT NULL, `dueDate` TEXT, `isFinished` INTEGER NOT NULL, `isTaskArchived` INTEGER NOT NULL, `dateAdded` TEXT, PRIMARY KEY(`taskID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE SET NULL )", "fields": [ { "fieldPath": "taskID", "columnName": "taskID", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subject", "columnName": "subject", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isImportant", "columnName": "isImportant", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "dueDate", "columnName": "dueDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isFinished", "columnName": "isFinished", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isTaskArchived", "columnName": "isTaskArchived", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "dateAdded", "columnName": "dateAdded", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "taskID" ], "autoGenerate": false }, "indices": [ { "name": "index_tasks_taskID", "unique": false, "columnNames": [ "taskID" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_taskID` ON `${TABLE_NAME}` (`taskID`)" } ], "foreignKeys": [ { "table": "subjects", "onDelete": "SET NULL", "onUpdate": "NO ACTION", "columns": [ "subject" ], "referencedColumns": [ "subjectID" ] } ] }, { "tableName": "attachments", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attachmentID` TEXT NOT NULL, `name` TEXT, `target` TEXT, `task` TEXT NOT NULL, `type` INTEGER NOT NULL, `dateAttached` TEXT, PRIMARY KEY(`attachmentID`), FOREIGN KEY(`task`) REFERENCES `tasks`(`taskID`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "attachmentID", "columnName": "attachmentID", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "target", "columnName": "target", "affinity": "TEXT", "notNull": false }, { "fieldPath": "task", "columnName": "task", "affinity": "TEXT", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "dateAttached", "columnName": "dateAttached", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "attachmentID" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "tasks", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "task" ], "referencedColumns": [ "taskID" ] } ] }, { "tableName": "logs", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`logID` TEXT NOT NULL, `title` TEXT, `content` TEXT, `data` TEXT, `type` INTEGER NOT NULL, `isImportant` INTEGER NOT NULL, `dateTimeTriggered` TEXT, PRIMARY KEY(`logID`))", "fields": [ { "fieldPath": "logID", "columnName": "logID", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": false }, { "fieldPath": "data", "columnName": "data", "affinity": "TEXT", "notNull": false }, { "fieldPath": "type", "columnName": "type", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isImportant", "columnName": "isImportant", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "dateTimeTriggered", "columnName": "dateTimeTriggered", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "logID" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "events", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventID` TEXT NOT NULL, `name` TEXT, `notes` TEXT, `location` TEXT, `subject` TEXT, `isImportant` INTEGER NOT NULL, `isEventArchived` INTEGER NOT NULL, `schedule` TEXT, `dateAdded` TEXT, PRIMARY KEY(`eventID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE SET NULL )", "fields": [ { "fieldPath": "eventID", "columnName": "eventID", "affinity": "TEXT", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "location", "columnName": "location", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subject", "columnName": "subject", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isImportant", "columnName": "isImportant", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isEventArchived", "columnName": "isEventArchived", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "schedule", "columnName": "schedule", "affinity": "TEXT", "notNull": false }, { "fieldPath": "dateAdded", "columnName": "dateAdded", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "eventID" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subjects", "onDelete": "SET NULL", "onUpdate": "NO ACTION", "columns": [ "subject" ], "referencedColumns": [ "subjectID" ] } ] }, { "tableName": "schedules", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scheduleID` TEXT NOT NULL, `classroom` TEXT, `daysOfWeek` INTEGER NOT NULL, `weeksOfMonth` INTEGER NOT NULL, `startTime` TEXT, `endTime` TEXT, `subject` TEXT, PRIMARY KEY(`scheduleID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "scheduleID", "columnName": "scheduleID", "affinity": "TEXT", "notNull": true }, { "fieldPath": "classroom", "columnName": "classroom", "affinity": "TEXT", "notNull": false }, { "fieldPath": "daysOfWeek", "columnName": "daysOfWeek", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "weeksOfMonth", "columnName": "weeksOfMonth", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "startTime", "columnName": "startTime", "affinity": "TEXT", "notNull": false }, { "fieldPath": "endTime", "columnName": "endTime", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subject", "columnName": "subject", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "scheduleID" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subjects", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "subject" ], "referencedColumns": [ "subjectID" ] } ] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5a325b51a216bf9c8987bdd216bf28b')" ] } } ================================================ FILE: app/src/androidTest/java/com/isaiahvonrundstedt/fokus/ExampleInstrumentedTest.kt ================================================ package com.isaiahvonrundstedt.fokus import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Test import org.junit.runner.RunWith import org.junit.Assert.* /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.isaiahvonrundstedt.digitaloasis", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/Fokus.kt ================================================ package com.isaiahvonrundstedt.fokus import android.app.Application import android.content.Context import android.net.Uri import androidx.core.content.FileProvider import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp import java.io.File import javax.inject.Inject @HiltAndroidApp class Fokus : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory override fun onCreate() { super.onCreate() DynamicColors.applyToActivitiesIfAvailable(this) } override fun getWorkManagerConfiguration(): Configuration { return Configuration.Builder() .setWorkerFactory(workerFactory) .build() } companion object { fun obtainUriForFile(context: Context, source: File): Uri { return FileProvider.getUriForFile( context, "${BuildConfig.APPLICATION_ID}.provider", source ) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/custom/ItemDecoration.kt ================================================ package com.isaiahvonrundstedt.fokus.components.custom import android.content.Context import androidx.recyclerview.widget.DividerItemDecoration class ItemDecoration(context: Context) : DividerItemDecoration(context, VERTICAL) ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/custom/ItemSwipeCallback.kt ================================================ package com.isaiahvonrundstedt.fokus.components.custom import android.content.Context import android.content.res.Configuration import android.graphics.Canvas import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.view.View import androidx.core.content.ContextCompat import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.interfaces.Swipeable class ItemSwipeCallback(context: Context, private var adapter: T) : ItemTouchHelper.Callback() { private var isThemeDark: Boolean = false private var iconDelete = ContextCompat.getDrawable(context, R.drawable.ic_outline_delete_24) private var iconArchive = ContextCompat.getDrawable(context, R.drawable.ic_outline_archive_24) private var backgroundDelete: Drawable private var backgroundArchive: Drawable init { isThemeDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val colorDelete = if (isThemeDark) Color.parseColor(COLOR_BACKGROUND_DARK_DELETE) else Color.parseColor(COLOR_BACKGROUND_LIGHT_DELETE) val colorArchive = if (isThemeDark) Color.parseColor(COLOR_BACKGROUND_DARK_ARCHIVE) else Color.parseColor(COLOR_BACKGROUND_LIGHT_ARCHIVE) backgroundDelete = ColorDrawable(colorDelete) backgroundArchive = ColorDrawable(colorArchive) } override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { return makeMovementFlags(0, ItemTouchHelper.START or ItemTouchHelper.END) } override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { adapter.onSwipe(viewHolder.bindingAdapterPosition, direction) } override fun getSwipeEscapeVelocity(defaultValue: Float): Float { return defaultValue * 10 } override fun onChildDraw( c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) val itemView: View = viewHolder.itemView val backgroundCornerOffset = 40 if (dX > 0) { with(backgroundArchive) { setBounds( itemView.left, itemView.top, itemView.left + dX.toInt() + backgroundCornerOffset, itemView.bottom ) draw(c) } iconArchive?.let { val tintColor = if (isThemeDark) Color.parseColor(COLOR_ICON_DARK_ARCHIVE) else Color.parseColor(COLOR_ICON_LIGHT_ARCHIVE) it.mutate() it.setTint(tintColor) val iconMargin: Int = (itemView.height - it.intrinsicHeight) / 2 val iconTop: Int = itemView.top + (itemView.height - it.intrinsicHeight) / 2 val iconBottom: Int = iconTop + it.intrinsicHeight val iconLeft: Int = itemView.left + iconMargin val iconRight: Int = iconLeft + it.intrinsicWidth it.setBounds(iconLeft, iconTop, iconRight, iconBottom) it.draw(c) } } else if (dX < 0) { with(backgroundDelete) { setBounds( itemView.right + dX.toInt() - backgroundCornerOffset, itemView.top, itemView.right, itemView.bottom ) draw(c) } iconDelete?.let { val tintColor = if (isThemeDark) Color.parseColor(COLOR_ICON_DARK_DELETE) else Color.parseColor(COLOR_ICON_LIGHT_DELETE) it.mutate() it.setTint(tintColor) val iconMargin: Int = (itemView.height - it.intrinsicHeight) / 2 val iconTop: Int = itemView.top + (itemView.height - it.intrinsicHeight) / 2 val iconBottom: Int = iconTop + it.intrinsicHeight val iconLeft: Int = itemView.right - iconMargin - it.intrinsicWidth val iconRight: Int = itemView.right - iconMargin it.setBounds(iconLeft, iconTop, iconRight, iconBottom) it.draw(c) } } } companion object { const val COLOR_ICON_LIGHT_DELETE = "#ea4335" const val COLOR_ICON_DARK_DELETE = "#000000" const val COLOR_ICON_LIGHT_ARCHIVE = "#00c853" const val COLOR_ICON_DARK_ARCHIVE = "#000000" const val COLOR_BACKGROUND_LIGHT_DELETE = "#66ea4335" const val COLOR_BACKGROUND_DARK_DELETE = "#ea4335" const val COLOR_BACKGROUND_LIGHT_ARCHIVE = "#6600c853" const val COLOR_BACKGROUND_DARK_ARCHIVE = "#00c853" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/enums/SortDirection.kt ================================================ package com.isaiahvonrundstedt.fokus.components.enums enum class SortDirection { ASCENDING, DESCENDING; companion object { fun parse(s: String?): SortDirection { return when (s) { ASCENDING.toString() -> ASCENDING DESCENDING.toString() -> DESCENDING else -> throw IllegalStateException("Sort Direction must be ascending or descending") } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/android/AppCompatExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.android import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Build import android.view.View import android.widget.Toast import androidx.annotation.DimenRes import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.Fragment import com.google.android.material.snackbar.Snackbar fun Context.isDark(): Boolean { return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES } fun Context.getDimension(@DimenRes res: Int): Int { return (resources.getDimension(res) / resources.displayMetrics.density).toInt() } fun Context.startForegroundServiceCompat(service: Intent) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(service) else startService(service) } fun AppCompatActivity.createSnackbar( @StringRes textRes: Int, view: View = window.decorView.rootView, duration: Int = Snackbar.LENGTH_SHORT ): Snackbar { return Snackbar.make(view, getString(textRes), duration).apply { show() } } fun AppCompatActivity.createToast( @StringRes textRes: Int, duration: Int = Toast.LENGTH_SHORT ): Toast { return Toast.makeText(this, getString(textRes), duration).apply { show() } } fun Fragment.createSnackbar( @StringRes textRes: Int, view: View = this.requireView(), duration: Int = Snackbar.LENGTH_SHORT ): Snackbar { return Snackbar.make(view, getString(textRes), duration).apply { show() } } fun Fragment.createToast( @StringRes textRes: Int, duration: Int = Toast.LENGTH_SHORT ): Toast { return Toast.makeText(requireContext(), getString(textRes), duration).apply { show() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/android/IntentExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.android import android.content.Intent import android.os.Parcelable import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toArrayList fun Intent.putExtra(key: String, items: List) { putParcelableArrayListExtra(key, items.toArrayList()) } fun Intent.getParcelableListExtra(key: String): List? { return getParcelableArrayListExtra(key)?.toList() } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/android/TextViewExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.android import android.graphics.Paint import android.graphics.drawable.Drawable import android.widget.TextView import androidx.annotation.ColorRes import androidx.core.content.ContextCompat /** * Extension function used to change the text color * of a AppCompatTextView * @param id a color id from the Android resource */ fun TextView.setTextColorFromResource(@ColorRes id: Int) { this.setTextColor(ContextCompat.getColor(this.context, id)) } /** * Extension function used to set an strike through effect on the * painted text on the view * @param status determines whether to add or remove the effect on the text */ fun TextView.setStrikeThroughEffect(status: Boolean) { if (status) this.paintFlags = this.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG else this.paintFlags = this.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() } /** * Extension function used to add a compound drawable in the * TextView at a specific position */ fun TextView.setCompoundDrawableAtStart(drawable: Drawable?) { this.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) } /** * Extension function used to get the compound drawable in the * TextView at the specific position */ fun TextView.getCompoundDrawableAtStart(): Drawable? { // Start, Top, End, Bottom return this.compoundDrawablesRelative[0] } /** * Extension function to remove the compound drawable * in the TextView at the specific position */ fun TextView.removeCompoundDrawableAtStart() { this.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/android/UriExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.android import android.content.Context import android.database.Cursor import android.net.Uri import android.provider.OpenableColumns /** * Extension function used to get the file name * of an uri object * @param context used to get access for a contentResolver object */ fun Uri.getFileName(context: Context): String { var result = "" if (this.scheme == "content") { val cursor: Cursor? = context.contentResolver?.query(this, null, null, null, null) try { if (cursor != null && cursor.moveToFirst()) result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) } catch (ex: Exception) { } finally { cursor?.close() } } else { result = this.path.toString() val index = result.lastIndexOf('/') if (index != 1) result = result.substring(index + 1) } return result } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/jdk/CalendarExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.jdk import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime import java.util.* /** * An extension function used to convert * the legacy Calendar instance to the * modern java.time.LocalTime instance * * @return the converted LocalTime instance */ fun Calendar.toLocalTime(): LocalTime { return LocalTime.of( this.get(Calendar.HOUR_OF_DAY), this.get(Calendar.MINUTE), this.get(Calendar.SECOND) ) } /** * An extension function used to convert * the legacy Calendar instance to the * modern java.time.ZonedDateTime instance * * @return the converted ZonedDateTime instance */ fun Calendar.toZonedDateTime(): ZonedDateTime? { return ZonedDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault()) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/jdk/ListExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.jdk import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.task.Task fun List.getIndexByID(id: String): Int { this.forEachIndexed { index, it -> if (it is Task) if (it.taskID == id) return index if (it is Event) if (it.eventID == id) return index if (it is Subject) if (it.subjectID == id) return index if (it is Attachment) if (it.attachmentID == id) return index if (it is Log) if (it.logID == id) return index if (it is Schedule) if (it.scheduleID == id) return index } return -1 } /** * Extension function to create an ArrayList * from the current List object */ fun List.toArrayList(): ArrayList { return ArrayList(this) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/extensions/jdk/TimeExtensions.kt ================================================ package com.isaiahvonrundstedt.fokus.components.extensions.jdk import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime import java.util.* /** * An extension function to convert the * LocalTime instance to a ZonedDateTime instance * with the current date * * @return the ZonedDateTime instance with the values of the * LocalTime instance */ fun LocalTime.toZonedDateTimeToday(): ZonedDateTime? { return LocalDate.now().atStartOfDay(ZoneId.systemDefault()) .with(this) } /** * An extension function used to * determine if the ZonedDateTime object * is same as the date today * * @return true if the date matches from the current date */ fun ZonedDateTime.isToday(): Boolean { return LocalDate.now().isEqual(this.toLocalDate()) } /** * An extension function used to * determine if the ZonedDateTime object * is the same as the next day * @return true if the ZonedDateTime object is the * same as the next day */ fun ZonedDateTime.isTomorrow(): Boolean { return LocalDate.now().plusDays(1).compareTo(this.toLocalDate()) == 0 } /** * An extension function used to * determine if the ZonedDateTime object * is the same as the previous day * @return true if the ZonedDateTime object is the * same as the previous day */ fun ZonedDateTime.isYesterday(): Boolean { return LocalDate.now().minusDays(1).compareTo(this.toLocalDate()) == 0 } /** * An extension function used to determine * if the ZonedDateTime object is before * the current datetime * @return true if the current ZonedDateTime object * is before the current date-time */ fun ZonedDateTime.isBeforeNow(): Boolean { return this.isBefore(ZonedDateTime.now()) } /** * An extension function used to determine * if the ZonedDateTime object is after * the current datetime * @return true if the current ZonedDateTime object * is after the current date-time */ fun ZonedDateTime.isAfterNow(): Boolean { return this.isAfter(ZonedDateTime.now()) } /** * An extension function used to convert the * current instance of ZonedDateTime to * a legacy Calendar instance * @return calendar instance with the same values as * this instance */ fun ZonedDateTime.toCalendar(): Calendar { return GregorianCalendar.from(this) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/interfaces/Streamable.kt ================================================ package com.isaiahvonrundstedt.fokus.components.interfaces import java.io.File import java.io.InputStream interface Streamable { fun toJsonString(): String? fun toJsonFile(destination: File, name: String): File fun fromInputStream(inputStream: InputStream) companion object { const val ARCHIVE_NAME_GENERIC = "exported" const val FILE_NAME_TASK = "task.json" const val FILE_NAME_ATTACHMENT = "attachment.json" const val FILE_NAME_SUBJECT = "subject.json" const val FILE_NAME_SCHEDULE = "schedule.json" const val FILE_NAME_EVENT = "event.json" const val FILE_NAME_LOG = "log.json" const val DIRECTORY_GENERIC = "others" const val DIRECTORY_ATTACHMENTS = "attachments" const val MIME_TYPE_ZIP = "application/zip" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/interfaces/Swipeable.kt ================================================ package com.isaiahvonrundstedt.fokus.components.interfaces interface Swipeable { fun onSwipe(position: Int, direction: Int) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/json/JsonDataStreamer.kt ================================================ package com.isaiahvonrundstedt.fokus.components.json import android.net.Uri import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.squareup.moshi.* import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okio.buffer import okio.source import java.io.InputStream import java.time.LocalTime import java.time.ZonedDateTime class JsonDataStreamer private constructor() { class DateTimeAdapter { @FromJson fun toDateTime(string: String): ZonedDateTime? = DateTimeConverter.toZonedDateTime(string) @ToJson fun fromDateTime(dateTime: ZonedDateTime): String? = DateTimeConverter.fromZonedDateTime(dateTime) } class LocalTimeAdapter { @FromJson fun toLocalTime(string: String): LocalTime? = DateTimeConverter.toLocalTime(string) @ToJson fun fromLocalTime(time: LocalTime): String? = DateTimeConverter.fromLocalTime(time) } class UriAdapter { @FromJson fun toUri(data: String): Uri = Uri.parse(data) @ToJson fun fromUri(uri: Uri): String = uri.toString() } companion object { val moshi: Moshi get() = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .add(DateTimeAdapter()) .add(LocalTimeAdapter()) .add(UriAdapter()) .build() fun encodeToJson(data: T?, dataType: Class): String? { if (data == null) return null val adapter: JsonAdapter = moshi.adapter(dataType) return adapter.toJson(data) } fun encodeToJson(dataItems: List?, dataType: Class): String? { if (dataItems == null || dataItems.isEmpty()) return null val type = Types.newParameterizedType(List::class.java, dataType) val adapter: JsonAdapter> = moshi.adapter(type) return adapter.toJson(dataItems) } fun decodeOnceFromJson(stream: InputStream, dataType: Class): T? { if (stream.isEmpty()) return null val adapter: JsonAdapter = moshi.adapter(dataType) return adapter.fromJson(stream.source().buffer()) } fun decodeFromJson(stream: InputStream, dataType: Class): List? { if (stream.isEmpty()) return emptyList() val type = Types.newParameterizedType(List::class.java, dataType) val adapter: JsonAdapter> = moshi.adapter(type) return adapter.fromJson(stream.source().buffer()) } private fun InputStream.isEmpty(): Boolean { return this.available() < 1 } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/json/Metadata.kt ================================================ package com.isaiahvonrundstedt.fokus.components.json import com.isaiahvonrundstedt.fokus.BuildConfig import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.database.AppDatabase import com.squareup.moshi.JsonClass import okio.buffer import okio.sink import java.io.File import java.io.InputStream /** * Metadata class handles exported json * data the is packaged through zip. * It reads what data are inside the package, * and if the corresponding data is compatible * with the application. */ @JsonClass(generateAdapter = true) data class Metadata @JvmOverloads constructor( var appVersion: Int = BuildConfig.VERSION_CODE, var appBuildName: String? = BuildConfig.VERSION_NAME, var databaseVersion: Int = AppDatabase.DATABASE_VERSION, var data: String? = null ) : Streamable { fun verify(dataString: String): Boolean { return databaseVersion == AppDatabase.DATABASE_VERSION && data == dataString } override fun toJsonString(): String? { return JsonDataStreamer.encodeToJson(this, Metadata::class.java) } override fun toJsonFile(destination: File, name: String): File { return File(destination, name).apply { this.sink().buffer().use { toJsonString()?.also { json -> it.write(json.toByteArray()) } } } } override fun fromInputStream(inputStream: InputStream) { JsonDataStreamer.decodeOnceFromJson(inputStream, Metadata::class.java)?.also { appVersion = it.appVersion appBuildName = it.appBuildName databaseVersion = it.databaseVersion data = it.data } } companion object { const val DATA_BUNDLE = "data:bundle" const val DATA_TASK = "data:task" const val DATA_SUBJECT = "data:subject" const val DATA_EVENT = "data:event" const val DATA_LOG = "data:log" const val FILE_NAME = "metadata.json" fun fromInputStream(inputStream: InputStream): Metadata { return Metadata().apply { this.fromInputStream(inputStream) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/modules/DatabaseModule.kt ================================================ package com.isaiahvonrundstedt.fokus.components.modules import android.app.NotificationManager import android.content.Context import androidx.work.WorkManager import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.AppDatabase import com.isaiahvonrundstedt.fokus.database.dao.* import com.isaiahvonrundstedt.fokus.database.repository.EventRepository import com.isaiahvonrundstedt.fokus.database.repository.LogRepository import com.isaiahvonrundstedt.fokus.database.repository.SubjectRepository import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class DatabaseModule { @Singleton @Provides fun provideDatabase(@ApplicationContext context: Context): AppDatabase { return AppDatabase.getInstance(context) } @Provides fun provideTaskDao(database: AppDatabase): TaskDAO = database.tasks() @Provides fun provideAttachmentDao(database: AppDatabase): AttachmentDAO = database.attachments() @Provides fun provideSubjectDao(database: AppDatabase): SubjectDAO = database.subjects() @Provides fun provideScheduleDao(database: AppDatabase): ScheduleDAO = database.schedules() @Provides fun provideEventDao(database: AppDatabase): EventDAO = database.events() @Provides fun provideLogDao(database: AppDatabase): LogDAO = database.logs() @Provides fun provideTaskRepository( @ApplicationContext context: Context, taskDao: TaskDAO, attachmentDao: AttachmentDAO, preferenceManager: PreferenceManager, workManager: WorkManager, notificationManager: NotificationManager ): TaskRepository { return TaskRepository( context, taskDao, attachmentDao, preferenceManager, workManager, notificationManager ) } @Provides fun provideSubjectRepository( @ApplicationContext context: Context, subjectDAO: SubjectDAO, scheduleDAO: ScheduleDAO, preferenceManager: PreferenceManager, workManager: WorkManager ): SubjectRepository { return SubjectRepository(context, subjectDAO, scheduleDAO, preferenceManager, workManager) } @Provides fun provideEventRepository( @ApplicationContext context: Context, eventDAO: EventDAO, preferenceManager: PreferenceManager, workManager: WorkManager, notificationManager: NotificationManager ): EventRepository { return EventRepository( context, eventDAO, preferenceManager, workManager, notificationManager ) } @Provides fun provideLogRepository(dao: LogDAO): LogRepository = LogRepository(dao) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/modules/ExternalModule.kt ================================================ package com.isaiahvonrundstedt.fokus.components.modules import android.app.NotificationManager import android.content.ClipboardManager import android.content.Context import androidx.work.WorkManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class ExternalModule { @Singleton @Provides fun provideWorkManager(@ApplicationContext context: Context): WorkManager { return WorkManager.getInstance(context) } @Provides fun provideNotificationManager(@ApplicationContext context: Context): NotificationManager { return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } @Provides fun provideClipboardManager(@ApplicationContext context: Context): ClipboardManager { return context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/modules/InternalModule.kt ================================================ package com.isaiahvonrundstedt.fokus.components.modules import android.content.Context import com.isaiahvonrundstedt.fokus.components.utils.PermissionManager import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class InternalModule { @Singleton @Provides fun providePreferenceManager(@ApplicationContext context: Context): PreferenceManager { return PreferenceManager(context) } @Singleton @Provides fun providePermissionManager(@ApplicationContext context: Context): PermissionManager { return PermissionManager(context) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/preference/InformationHolder.kt ================================================ package com.isaiahvonrundstedt.fokus.components.preference import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import androidx.core.content.res.TypedArrayUtils import androidx.preference.Preference import com.isaiahvonrundstedt.fokus.R @SuppressLint("RestrictedApi") class InformationHolder( context: Context, attr: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : Preference(context, attr, defStyleAttr, defStyleRes) { constructor(context: Context, attr: AttributeSet?, defStyleAttr: Int) : this(context, attr, defStyleAttr, 0) constructor(context: Context, attr: AttributeSet?) : this( context, attr, TypedArrayUtils.getAttr( context, androidx.preference.R.attr.preferenceStyle, android.R.attr.preferenceStyle ) ) constructor(context: Context) : this(context, null) init { layoutResource = R.layout.layout_preference_info } override fun onClick() {} override fun performClick() {} } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/receiver/LocalizationReceiver.kt ================================================ package com.isaiahvonrundstedt.fokus.components.receiver import android.app.NotificationManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build import com.isaiahvonrundstedt.fokus.components.utils.NotificationChannelManager class LocalizationReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (intent?.action == Intent.ACTION_LOCALE_CHANGED) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { with(NotificationChannelManager(context!!)) { register( NotificationChannelManager.CHANNEL_ID_GENERIC, NotificationManager.IMPORTANCE_DEFAULT ) register( NotificationChannelManager.CHANNEL_ID_TASK, groupID = NotificationChannelManager.CHANNEL_GROUP_ID_REMINDERS ) register( NotificationChannelManager.CHANNEL_ID_EVENT, groupID = NotificationChannelManager.CHANNEL_GROUP_ID_REMINDERS ) register( NotificationChannelManager.CHANNEL_ID_CLASS, groupID = NotificationChannelManager.CHANNEL_GROUP_ID_REMINDERS ) } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/service/BackupRestoreService.kt ================================================ package com.isaiahvonrundstedt.fokus.components.service import android.content.Intent import android.net.Uri import android.os.Environment import android.os.IBinder import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.components.json.Metadata import com.isaiahvonrundstedt.fokus.components.utils.DataArchiver import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.AppDatabase import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.task.Task import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import okio.buffer import okio.sink import org.apache.commons.io.FileUtils import java.io.EOFException import java.io.File import java.io.InputStream import java.time.ZonedDateTime import java.util.zip.ZipEntry import java.util.zip.ZipFile class BackupRestoreService : BaseService() { private lateinit var database: AppDatabase override fun onCreate() { super.onCreate() database = AppDatabase.getInstance(this) } override fun onBind(intent: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { intent?.data?.also { when (intent.action) { ACTION_BACKUP -> startBackup(it) ACTION_RESTORE -> startRestore(it) } } return START_NOT_STICKY } private fun startRestore(uri: Uri) { startForegroundCompat( NOTIFICATION_RESTORE_ONGOING, createNotification( true, R.string.notification_restore_ongoing, iconRes = R.drawable.ic_outline_file_download_24 ) ) try { val archiveStream: InputStream? = contentResolver.openInputStream(uri) val archive = DataArchiver.parseInputStream(this, archiveStream) archive.getInputStream(archive.getEntry(Metadata.FILE_NAME))?.use { val metadata = Metadata.fromInputStream(it) if (!metadata.verify(Metadata.DATA_BUNDLE)) { stopForegroundCompat(NOTIFICATION_RESTORE_ONGOING) manager?.notify( NOTIFICATION_RESTORE_FAILED, createNotification(titleRes = R.string.notification_restore_error) ) terminateService() archive.close() } } for (entry: ZipEntry in archive.entries()) { archive.getInputStream(entry)?.use { tryParse(archive, entry, it) } } stopForegroundCompat(NOTIFICATION_RESTORE_ONGOING) manager?.notify( NOTIFICATION_RESTORE_SUCCESS, createNotification(titleRes = R.string.notification_restore_success) ) terminateService() archive.close() } catch (e: EOFException) { e.printStackTrace() stopForegroundCompat(NOTIFICATION_RESTORE_ONGOING) manager?.notify( NOTIFICATION_RESTORE_FAILED, createNotification( titleRes = R.string.notification_restore_error, contentRes = R.string.feedback_restore_corrupted ) ) terminateService() } catch (e: Exception) { e.printStackTrace() stopForegroundCompat(NOTIFICATION_RESTORE_ONGOING) manager?.notify( NOTIFICATION_RESTORE_FAILED, createNotification( titleRes = R.string.notification_restore_error, contentRes = R.string.feedback_restore_invalid ) ) terminateService() } } private fun tryParse(archive: ZipFile, entry: ZipEntry, stream: InputStream) { if (entry.name == Streamable.FILE_NAME_SUBJECT) { JsonDataStreamer.decodeFromJson(stream, Subject::class.java)?.run { runBlocking { forEach { database.subjects().insert(it) } } } } else if (entry.name == Streamable.FILE_NAME_SCHEDULE) { JsonDataStreamer.decodeFromJson(stream, Schedule::class.java)?.run { runBlocking { forEach { database.schedules().insert(it) } } } } else if (entry.name == Streamable.FILE_NAME_TASK) { JsonDataStreamer.decodeFromJson(stream, Task::class.java)?.run { runBlocking { forEach { database.tasks().insert(it) } } } } else if (entry.name == Streamable.FILE_NAME_ATTACHMENT) { JsonDataStreamer.decodeFromJson(stream, Attachment::class.java)?.run { runBlocking { forEach { database.attachments().insert(it) } } } } else if (entry.name == Streamable.FILE_NAME_EVENT) { JsonDataStreamer.decodeFromJson(stream, Event::class.java)?.run { runBlocking { forEach { database.events().insert(it) } } } } else if (entry.name == Streamable.FILE_NAME_LOG) { JsonDataStreamer.decodeFromJson(stream, Log::class.java)?.run { runBlocking { forEach { database.logs().insert(it) } } } } else if (entry.name.contains(Streamable.DIRECTORY_ATTACHMENTS) && !entry.isDirectory ) { val targetDirectory = File( getExternalFilesDir(null), Streamable.DIRECTORY_ATTACHMENTS ) val destination = File(targetDirectory, File(entry.name).name) archive.getInputStream(entry)?.use { inputStream -> FileUtils.copyToFile(inputStream, destination) } } } private fun startBackup(destination: Uri) { if (Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED) return startForegroundCompat( NOTIFICATION_BACKUP_ONGOING, createNotification( ongoing = true, titleRes = R.string.notification_backup_ongoing, iconRes = R.drawable.ic_outline_file_upload_24 ) ) try { runBlocking { val items = mutableListOf() var fetchJob: Job fetchJob = async { database.subjects().fetch() } JsonDataStreamer.encodeToJson(fetchJob.await(), Subject::class.java)?.let { items.add(createCache(Streamable.FILE_NAME_SUBJECT, it)) } fetchJob = async { database.schedules().fetch() } JsonDataStreamer.encodeToJson(fetchJob.await(), Schedule::class.java)?.let { items.add(createCache(Streamable.FILE_NAME_SCHEDULE, it)) } fetchJob = async { database.tasks().fetch() } JsonDataStreamer.encodeToJson(fetchJob.await(), Task::class.java)?.let { items.add(createCache(Streamable.FILE_NAME_TASK, it)) } fetchJob = async { database.attachments().fetch() } val attachments: List? = fetchJob.await() JsonDataStreamer.encodeToJson(attachments, Attachment::class.java)?.let { items.add(createCache(Streamable.FILE_NAME_ATTACHMENT, it)) } val attachmentFolder = File( cacheDir, Streamable.DIRECTORY_ATTACHMENTS ) if (!attachmentFolder.exists()) attachmentFolder.mkdir() attachments?.forEach { if (it.type == Attachment.TYPE_IMPORTED_FILE && it.target != null) FileUtils.copyFileToDirectory( File(it.target!!), attachmentFolder ) } items.add(attachmentFolder) fetchJob = async { database.events().fetch() } JsonDataStreamer.encodeToJson(fetchJob.await(), Event::class.java)?.let { items.add(createCache(Streamable.FILE_NAME_EVENT, it)) } fetchJob = async { database.logs().fetchCore() } JsonDataStreamer.encodeToJson(fetchJob.await(), Log::class.java)?.let { items.add(createCache(Streamable.FILE_NAME_LOG, it)) } items.add( Metadata(data = Metadata.DATA_BUNDLE) .toJsonFile(cacheDir, Metadata.FILE_NAME) ) if (items.isEmpty()) { stopForegroundCompat(NOTIFICATION_BACKUP_ONGOING) terminateService(BROADCAST_BACKUP_EMPTY) } DataArchiver.Create(this@BackupRestoreService) .addSource(items) .toDestination(destination) .start() PreferenceManager(this@BackupRestoreService) .previousBackupDate = ZonedDateTime.now() stopForegroundCompat(NOTIFICATION_BACKUP_ONGOING) manager?.notify( NOTIFICATION_BACKUP_SUCCESS, createNotification(titleRes = R.string.notification_backup_success) ) terminateService(BROADCAST_BACKUP_SUCCESS) } } catch (e: Exception) { e.printStackTrace() stopForegroundCompat(NOTIFICATION_BACKUP_ONGOING) manager?.notify( NOTIFICATION_BACKUP_FAILED, createNotification(titleRes = R.string.notification_backup_error) ) terminateService(BROADCAST_BACKUP_FAILED) } } private fun createCache(name: String, json: String): File { return File(cacheDir, name).apply { this.sink().buffer().use { it.write(json.toByteArray()) it.flush() } } } companion object { const val FILE_BACKUP_NAME = "backup" const val ACTION_BACKUP = "action:backup" const val ACTION_RESTORE = "action:restore" const val NOTIFICATION_BACKUP_ONGOING = 1 const val NOTIFICATION_BACKUP_SUCCESS = 2 const val NOTIFICATION_BACKUP_FAILED = 3 const val NOTIFICATION_RESTORE_ONGOING = 4 const val NOTIFICATION_RESTORE_SUCCESS = 5 const val NOTIFICATION_RESTORE_FAILED = 6 const val BROADCAST_BACKUP_SUCCESS = "broadcast:backup:success" const val BROADCAST_BACKUP_FAILED = "broadcast:backup:failed" const val BROADCAST_BACKUP_EMPTY = "broadcast:backup:empty" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/service/DataExporterService.kt ================================================ package com.isaiahvonrundstedt.fokus.components.service import android.content.Intent import android.net.Uri import android.os.IBinder import com.isaiahvonrundstedt.fokus.components.extensions.android.getParcelableListExtra import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.Metadata import com.isaiahvonrundstedt.fokus.components.utils.DataArchiver import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.task.Task import java.io.File class DataExporterService : BaseService() { override fun onBind(intent: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { onExport(intent) sendLocalBroadcast(BROADCAST_EXPORT_ONGOING) return START_REDELIVER_INTENT } private fun onExport(intent: Intent?) { if (intent?.hasExtra(EXTRA_EXPORT_SOURCE) == false) terminateService(BROADCAST_EXPORT_FAILED) val destination: Uri? = intent?.data val items = mutableListOf() var fileName: String = Streamable.ARCHIVE_NAME_GENERIC try { when (intent?.action) { ACTION_EXPORT_SUBJECT -> { items.add( Metadata(data = Metadata.DATA_SUBJECT) .toJsonFile(cacheDir, Metadata.FILE_NAME) ) val subject: Subject? = intent.getParcelableExtra(EXTRA_EXPORT_SOURCE) val schedules: List? = intent.getParcelableListExtra(EXTRA_EXPORT_DEPENDENTS) if (subject != null) { fileName = subject.code ?: Streamable.ARCHIVE_NAME_GENERIC items.add(subject.toJsonFile(cacheDir, Streamable.FILE_NAME_SUBJECT)) } items.add(Schedule.toJsonFile(schedules ?: emptyList(), cacheDir)) } ACTION_EXPORT_TASK -> { items.add( Metadata(data = Metadata.DATA_TASK) .toJsonFile(cacheDir, Metadata.FILE_NAME) ) val task: Task? = intent.getParcelableExtra(EXTRA_EXPORT_SOURCE) if (task != null) { fileName = task.name ?: Streamable.ARCHIVE_NAME_GENERIC items.add(task.toJsonFile(cacheDir, Streamable.FILE_NAME_TASK)) } var attachments: List = intent.getParcelableListExtra(EXTRA_EXPORT_DEPENDENTS) ?: emptyList() attachments = attachments.filter { it.type == Attachment.TYPE_WEBSITE_LINK } items.add(Attachment.toJsonFile(attachments, cacheDir)) } ACTION_EXPORT_EVENT -> { items.add( Metadata(data = Metadata.DATA_EVENT) .toJsonFile(cacheDir, Metadata.FILE_NAME) ) val event: Event? = intent.getParcelableExtra(EXTRA_EXPORT_SOURCE) if (event != null) { fileName = event.name ?: Streamable.ARCHIVE_NAME_GENERIC items.add(event.toJsonFile(cacheDir, Streamable.FILE_NAME_EVENT)) } } } if (destination == null) { val cache = File(externalCacheDir, fileName) DataArchiver.Create(this) .addSource(items) .toDestination(cache) .start() terminateService(BROADCAST_EXPORT_COMPLETED, cache.path) } else { DataArchiver.Create(this) .addSource(items) .toDestination(destination) .start() terminateService(BROADCAST_EXPORT_COMPLETED) } } catch (e: Exception) { terminateService(BROADCAST_EXPORT_FAILED) } } companion object { const val EXTRA_EXPORT_SOURCE = "extra:export:source" const val EXTRA_EXPORT_DEPENDENTS = "extra:export:dependents" const val ACTION_EXPORT_SUBJECT = "action:export:subject" const val ACTION_EXPORT_TASK = "action:export:task" const val ACTION_EXPORT_EVENT = "action:export:event" const val BROADCAST_EXPORT_ONGOING = "broadcast:export:ongoing" const val BROADCAST_EXPORT_COMPLETED = "broadcast:export:completed" const val BROADCAST_EXPORT_FAILED = "broadcast:export:failed" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/service/DataImporterService.kt ================================================ package com.isaiahvonrundstedt.fokus.components.service import android.content.Intent import android.os.IBinder import android.os.Parcelable import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.components.json.Metadata import com.isaiahvonrundstedt.fokus.components.utils.DataArchiver import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.event.EventPackage import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import com.isaiahvonrundstedt.fokus.features.task.Task import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import java.util.zip.ZipEntry class DataImporterService : BaseService() { override fun onBind(intent: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { onImport(intent) return START_REDELIVER_INTENT } private fun onImport(intent: Intent?) { if (intent?.data == null) terminateService(BROADCAST_IMPORT_FAILED) contentResolver.openInputStream(intent?.data!!)?.use { inputStream -> val archive = DataArchiver.parseInputStream(this, inputStream) try { archive.getInputStream(archive.getEntry(Metadata.FILE_NAME)).use { it -> val metadata = Metadata.fromInputStream(it) if (metadata.verify(Metadata.DATA_SUBJECT) && intent.action == ACTION_IMPORT_SUBJECT ) { val subjectPackage = SubjectPackage(Subject()) for (entry: ZipEntry in archive.entries()) { if (entry.name == Streamable.FILE_NAME_SUBJECT) { archive.getInputStream(entry)?.use { inputStream -> subjectPackage.subject = Subject.fromInputStream(inputStream) } } else if (entry.name == Streamable.FILE_NAME_SCHEDULE) { archive.getInputStream(entry)?.use { inputStream -> JsonDataStreamer.decodeFromJson( inputStream, Schedule::class.java ) ?.also { items -> subjectPackage.schedules = items } } } } sendResult(subjectPackage) } else if (metadata.verify(Metadata.DATA_TASK) && intent.action == ACTION_IMPORT_TASK ) { val taskPackage = TaskPackage(Task()) for (entry: ZipEntry in archive.entries()) { if (entry.name == Streamable.FILE_NAME_TASK) { archive.getInputStream(entry)?.use { inputStream -> taskPackage.task = Task.fromInputStream(inputStream) } } else if (entry.name == Streamable.FILE_NAME_ATTACHMENT) { archive.getInputStream(entry)?.use { inputStream -> JsonDataStreamer.decodeFromJson( inputStream, Attachment::class.java ) ?.also { items -> taskPackage.attachments = items } } } } sendResult(taskPackage) } else if (metadata.verify(Metadata.DATA_EVENT) && intent.action == ACTION_IMPORT_EVENT ) { val eventPackage = EventPackage(Event()) for (entry: ZipEntry in archive.entries()) { if (entry.name == Streamable.FILE_NAME_EVENT) { archive.getInputStream(entry)?.use { inputStream -> eventPackage.event = Event.fromInputStream(inputStream) } } } sendResult(eventPackage) } else terminateService(BROADCAST_IMPORT_FAILED) } } catch (e: Exception) { e.printStackTrace() terminateService(BROADCAST_IMPORT_FAILED) } } } private fun sendResult(t: T) { LocalBroadcastManager.getInstance(this) .sendBroadcast(Intent(ACTION_SERVICE_BROADCAST).apply { putExtra(EXTRA_BROADCAST_STATUS, BROADCAST_IMPORT_COMPLETED) putExtra(EXTRA_BROADCAST_DATA, t) }) terminateService() } companion object { const val ACTION_IMPORT_TASK = "action:import:task" const val ACTION_IMPORT_SUBJECT = "action:import:subject" const val ACTION_IMPORT_EVENT = "action:import:event" const val BROADCAST_IMPORT_ONGOING = "broadcast:import:ongoing" const val BROADCAST_IMPORT_COMPLETED = "broadcast:import:completed" const val BROADCAST_IMPORT_FAILED = "broadcast:import:failed" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/service/FileImporterService.kt ================================================ package com.isaiahvonrundstedt.fokus.components.service import android.content.Intent import android.net.Uri import android.os.Environment import android.os.IBinder import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.isaiahvonrundstedt.fokus.components.extensions.android.getFileName import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import org.apache.commons.io.FileUtils import java.io.File class FileImporterService : BaseService() { private lateinit var targetDirectory: File override fun onBind(intent: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START -> { targetDirectory = File( getExternalFilesDir(null), Streamable.DIRECTORY_ATTACHMENTS ) intent.data?.let { onStartCopy(it, intent.getStringExtra(EXTRA_OBJECT_ID)!!) } } ACTION_CANCEL -> terminateService() } return START_REDELIVER_INTENT } private fun onStartCopy(uri: Uri, id: String) { // Check if we have access to the storage if (Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED) { terminateService(BROADCAST_IMPORT_FAILED) return } sendLocalBroadcast(BROADCAST_IMPORT_ONGOING) try { contentResolver.openInputStream(uri)?.use { // Use the attachment id to link the raw file // to the database // note: need to remove the target column in the database as it becomes redundant. val fileName = uri.getFileName(this) val extension = File(fileName).extension val targetFile = File(targetDirectory, "${id}.${extension}") FileUtils.copyToFile(it, targetFile) broadcastResultThenTerminate(id, fileName) } } catch (e: Exception) { e.printStackTrace() terminateService(BROADCAST_IMPORT_FAILED) } } /** * Sends the required data back to the activity then * terminates itself. * @param id the attachment id that the file will be linked in * @param name the original file name of the file */ private fun broadcastResultThenTerminate(id: String, name: String) { LocalBroadcastManager.getInstance(this) .sendBroadcast(Intent(ACTION_SERVICE_BROADCAST).apply { // This function is only called when the import is // completed and therefore we should just // put a completed status in the broadcast putExtra(EXTRA_BROADCAST_STATUS, BROADCAST_IMPORT_COMPLETED) // Send the attachment id back to the calling activity putExtra(EXTRA_BROADCAST_DATA, id) // Send the file name back to the calling activity putExtra(EXTRA_BROADCAST_EXTRA, name) }) stopSelf() } companion object { const val ACTION_START = "action:start" const val ACTION_CANCEL = "action:cancel" const val EXTRA_OBJECT_ID = "extra:id" const val EXTRA_BROADCAST_EXTRA = "extra:broadcast:extra" const val BROADCAST_IMPORT_ONGOING = "broadcast:attachment:ongoing" const val BROADCAST_IMPORT_COMPLETED = "broadcast:attachment:completed" const val BROADCAST_IMPORT_FAILED = "broadcast:attachment:failed" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/service/NotificationActionService.kt ================================================ package com.isaiahvonrundstedt.fokus.components.service import android.app.IntentService import android.app.NotificationManager import android.content.Context import android.content.Intent import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.isaiahvonrundstedt.fokus.features.core.worker.ActionWorker import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker // This service function is to trigger the worker // that will perform the appropriate action // based on what the user tapped on the fokus // Since PendingIntents can trigger Workers, this service // acts like a middle man class NotificationActionService : IntentService(SERVICE_NAME) { private val manager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager } @Deprecated("Deprecated in Java") override fun onHandleIntent(intent: Intent?) { val taskID = intent?.getStringExtra(EXTRA_TASK_ID) val isPersistent = intent?.getBooleanExtra(EXTRA_IS_PERSISTENT, false) ?: false if (isPersistent) manager?.cancel(taskID, BaseWorker.NOTIFICATION_ID_TASK) else manager?.cancel(BaseWorker.NOTIFICATION_TAG_TASK, BaseWorker.NOTIFICATION_ID_TASK) val data = Data.Builder() data.putString(EXTRA_TASK_ID, taskID) if (intent?.action == ACTION_FINISHED) data.putString(EXTRA_ACTION, ACTION_FINISHED) val workRequest = OneTimeWorkRequest.Builder(ActionWorker::class.java) .setInputData(data.build()) .addTag(ActionWorker::class.java.simpleName) .build() WorkManager.getInstance(this).enqueue(workRequest) } companion object { const val SERVICE_NAME = "service:notification:actions" const val EXTRA_TASK_ID = "extra:taskID" const val EXTRA_IS_PERSISTENT = "extra:isPersistent" const val EXTRA_ACTION = "extra:action" const val ACTION_FINISHED = "action:finished" const val NOTIFICATION_ID_FINISH = 28 } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/utils/DataArchiver.kt ================================================ package com.isaiahvonrundstedt.fokus.components.utils import android.content.Context import android.net.Uri import org.apache.commons.io.FileUtils import java.io.* import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream class DataArchiver private constructor(private var context: Context) { private var uri: Uri? = null private var destination: File? = null private var items = mutableListOf() fun archive() { if (destination != null) { FileOutputStream(destination).use { ZipOutputStream(BufferedOutputStream(it)).use { outputStream -> setEntries(outputStream) } } } else if (uri != null) { context.contentResolver.openOutputStream(uri!!)?.use { ZipOutputStream(BufferedOutputStream(it)).use { outputStream -> setEntries(outputStream) } } } } private fun setEntries(zip: ZipOutputStream) { items.forEach { if (it.isDirectory) { zip.putNextEntry(ZipEntry("${it.name}/")) for (source: File in it.listFiles()!!) { onCopyToOutputStream(zip, "${it.name}/${source.name}", source) } } else onCopyToOutputStream(zip, it.name, it) } } private fun onCopyToOutputStream(zip: ZipOutputStream, entryName: String, source: File) { BufferedInputStream(FileInputStream(source), BUFFER).use { stream -> zip.putNextEntry(ZipEntry(entryName)) stream.copyTo(zip, BUFFER) } } class Create(context: Context) { private var zip = DataArchiver(context) fun addSource(items: List): Create { zip.items.addAll(items) return this } fun addSource(file: File): Create { zip.items.add(file) return this } fun toDestination(uri: Uri): Create { zip.uri = uri return this } fun toDestination(file: File): Create { zip.destination = file return this } fun start() = zip.archive() } companion object { private const val BUFFER = 4096 private const val FILE_TEMP_WORKING_FILE = "temp.fts" fun parseInputStream(context: Context, stream: InputStream?): ZipFile { val temp = File(context.cacheDir, FILE_TEMP_WORKING_FILE) FileUtils.copyToFile(stream, temp) return ZipFile(temp) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/utils/NotificationChannelManager.kt ================================================ package com.isaiahvonrundstedt.fokus.components.utils import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager import android.content.Context import android.media.AudioAttributes import android.net.Uri import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.core.app.NotificationManagerCompat import com.isaiahvonrundstedt.fokus.R class NotificationChannelManager(private var context: Context) { private val manager = NotificationManagerCompat.from(context) @RequiresApi(26) fun register( id: String, importance: Int = NotificationManager.IMPORTANCE_HIGH, groupID: String? = null ) { if (groupID != null && manager.getNotificationChannelGroup(groupID) == null) { manager.createNotificationChannelGroup(createGroup(groupID)) } manager.createNotificationChannel(createChannel(id, importance, groupID)) } @RequiresApi(26) private fun createChannel( id: String, importance: Int = NotificationManager.IMPORTANCE_HIGH, groupID: String? ): NotificationChannel { return NotificationChannel(id, context.getString(getChannelNameRes(id)), importance).apply { setSound(notificationSoundUri, attributes) group = groupID } } @RequiresApi(26) private fun createGroup(id: String): NotificationChannelGroup { return NotificationChannelGroup(id, context.getString(getGroupNameRes(id))) } @StringRes private fun getChannelNameRes(id: String): Int { return when (id) { CHANNEL_ID_TASK -> R.string.notification_channel_task_reminders CHANNEL_ID_EVENT -> R.string.notification_channel_event_reminders CHANNEL_ID_CLASS -> R.string.notification_channel_class_reminders CHANNEL_ID_GENERIC -> R.string.notification_channel_general else -> 0 } } @StringRes private fun getGroupNameRes(id: String): Int { return when (id) { CHANNEL_GROUP_ID_REMINDERS -> R.string.notification_channel_group_reminders else -> 0 } } private val attributes: AudioAttributes get() = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .build() private val notificationSoundUri: Uri get() = Uri.parse(PreferenceManager.DEFAULT_SOUND) companion object { const val CHANNEL_ID_TASK = "channel:task" const val CHANNEL_ID_EVENT = "channel:event" const val CHANNEL_ID_CLASS = "channel:class" const val CHANNEL_ID_GENERIC = "channel:generic" const val CHANNEL_GROUP_ID_REMINDERS = "group:reminders" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/utils/PermissionManager.kt ================================================ package com.isaiahvonrundstedt.fokus.components.utils import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build class PermissionManager(var context: Context) { val readStorageGranted: Boolean get() { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED else true } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/utils/PreferenceManager.kt ================================================ package com.isaiahvonrundstedt.fokus.components.utils import android.content.ContentResolver import android.content.Context import androidx.preference.PreferenceManager import com.isaiahvonrundstedt.fokus.BuildConfig import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.enums.SortDirection import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.subject.SubjectViewModel import com.isaiahvonrundstedt.fokus.features.task.TaskViewModel import java.time.LocalTime import java.time.ZonedDateTime class PreferenceManager(private val context: Context) { enum class Theme { SYSTEM, DARK, LIGHT; companion object { fun parse(s: String?): Theme { return when (s) { DARK.toString() -> DARK LIGHT.toString() -> LIGHT else -> SYSTEM } } } } var theme: Theme get() = Theme.parse( sharedPreference.getString( PREFERENCE_THEME, Theme.SYSTEM.toString() ) ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_THEME, value.toString()) apply() } } var previousBackupDate: ZonedDateTime? get() = DateTimeConverter.toZonedDateTime( sharedPreference.getString(PREFERENCE_BACKUP, null) ) set(value) { sharedPreference.edit().run { putString( PREFERENCE_BACKUP, DateTimeConverter.fromZonedDateTime(value) ) apply() } } var reminderTime: LocalTime? get() = DateTimeConverter.toLocalTime( sharedPreference.getString(PREFERENCE_REMINDER_TIME, "08:30") ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_REMINDER_TIME, DateTimeConverter.fromLocalTime(value)) apply() } } var taskConstraint: TaskViewModel.Constraint get() = TaskViewModel.Constraint.parse( sharedPreference.getString( PREFERENCE_TASK_FILTER_OPTION, TaskViewModel.Constraint.ALL.toString() ) ?: TaskViewModel.Constraint.ALL.toString() ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_TASK_FILTER_OPTION, value.toString()) apply() } } var tasksSort: TaskViewModel.Sort get() = TaskViewModel.Sort.parse( sharedPreference.getString( PREFERENCE_TASK_SORT_OPTION, TaskViewModel.Sort.NAME.toString() ) ?: TaskViewModel.Sort.NAME.toString() ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_TASK_SORT_OPTION, value.toString()) apply() } } var tasksSortDirection: SortDirection get() = SortDirection.parse( sharedPreference.getString( PREFERENCE_TASK_SORT_DIRECTION, SortDirection.ASCENDING.toString() ) ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_TASK_SORT_DIRECTION, value.toString()) apply() } } var subjectConstraint: SubjectViewModel.Constraint get() = SubjectViewModel.Constraint.parse( sharedPreference.getString( PREFERENCE_SUBJECT_FILTER_OPTION, SubjectViewModel.Constraint.ALL.toString() ) ?: SubjectViewModel.Constraint.ALL.toString() ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_SUBJECT_FILTER_OPTION, value.toString()) apply() } } var subjectSort: SubjectViewModel.Sort get() = SubjectViewModel.Sort.parse( sharedPreference.getString( PREFERENCE_SUBJECT_SORT_OPTION, SubjectViewModel.Sort.CODE.toString() ) ?: SubjectViewModel.Sort.CODE.toString() ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_SUBJECT_SORT_OPTION, value.toString()) apply() } } var subjectSortDirection: SortDirection get() = SortDirection.parse( sharedPreference.getString( PREFERENCE_SUBJECT_SORT_DIRECTION, SortDirection.ASCENDING.toString() ) ?: SubjectViewModel.Sort.CODE.toString() ) set(value) { sharedPreference.edit().run { putString(PREFERENCE_SUBJECT_SORT_DIRECTION, value.toString()) apply() } } val confetti: Boolean get() = sharedPreference.getBoolean(PREFERENCE_CONFETTI, true) val sounds: Boolean get() = sharedPreference.getBoolean(PREFERENCE_SOUND, true) val taskReminder: Boolean get() = sharedPreference.getBoolean(PREFERENCE_TASK_NOTIFICATION, true) val eventReminder: Boolean get() = sharedPreference.getBoolean(PREFERENCE_EVENT_NOTIFICATION, true) val subjectReminder: Boolean get() = sharedPreference.getBoolean(PREFERENCE_COURSE_NOTIFICATION, true) val useExternalBrowser: Boolean get() = sharedPreference.getBoolean(PREFERENCE_USE_EXTERNAL_BROWSER, false) val allowWeekNumbers: Boolean get() = sharedPreference.getBoolean(PREFERENCE_ALLOW_WEEK_NUMBERS, false) val reminderFrequency: String get() = sharedPreference.getString( PREFERENCE_REMINDER_FREQUENCY, DURATION_EVERYDAY ) ?: DURATION_EVERYDAY val taskReminderInterval: String get() = sharedPreference.getString( PREFERENCE_TASK_NOTIFICATION_INTERVAL, TASK_REMINDER_INTERVAL_3_HOURS ) ?: TASK_REMINDER_INTERVAL_3_HOURS val eventReminderInterval: String get() = sharedPreference.getString( PREFERENCE_EVENT_NOTIFICATION_INTERVAL, EVENT_REMINDER_INTERVAL_30_MINUTES ) ?: EVENT_REMINDER_INTERVAL_30_MINUTES val subjectReminderInterval: String get() = sharedPreference.getString( PREFERENCE_COURSE_NOTIFICATION_INTERVAL, SUBJECT_REMINDER_INTERVAL_30_MINUTES ) ?: SUBJECT_REMINDER_INTERVAL_30_MINUTES /* User-Defined Settings */ var noConfirmImport: Boolean get() = sharedPreference.getBoolean( PREFERENCE_NO_CONFIRM_IMPORT, false ) set(value) { sharedPreference.edit().run { putBoolean(PREFERENCE_NO_CONFIRM_IMPORT, value) commit() } } private val sharedPreference by lazy { PreferenceManager.getDefaultSharedPreferences(context) } companion object { const val DEFAULT_SOUND = "${ContentResolver.SCHEME_ANDROID_RESOURCE}://${BuildConfig.APPLICATION_ID}/${R.raw.fokus}" const val DURATION_EVERYDAY = "EVERYDAY" const val DURATION_WEEKENDS = "WEEKENDS" const val TASK_REMINDER_INTERVAL_1_HOUR = "1" const val TASK_REMINDER_INTERVAL_3_HOURS = "3" const val TASK_REMINDER_INTERVAL_24_HOURS = "24" const val EVENT_REMINDER_INTERVAL_15_MINUTES = "15" const val EVENT_REMINDER_INTERVAL_30_MINUTES = "30" const val EVENT_REMINDER_INTERVAL_60_MINUTES = "60" const val SUBJECT_REMINDER_INTERVAL_5_MINUTES = "5" const val SUBJECT_REMINDER_INTERVAL_15_MINUTES = "15" const val SUBJECT_REMINDER_INTERVAL_30_MINUTES = "30" // Preferences that are visible in the SettingsActivity const val PREFERENCE_THEME = "KEY_THEME" const val PREFERENCE_CONFETTI = "KEY_CONFETTI" const val PREFERENCE_SOUND = "KEY_SOUND" const val PREFERENCE_REMINDER_FREQUENCY = "KEY_REMINDER_FREQUENCY" const val PREFERENCE_REMINDER_TIME = "KEY_REMINDER_TIME" const val PREFERENCE_TASK_NOTIFICATION = "KEY_TASK_NOTIFICATION" const val PREFERENCE_TASK_NOTIFICATION_INTERVAL = "KEY_TASK_NOTIFICATION_INTERVAL" const val PREFERENCE_EVENT_NOTIFICATION = "KEY_EVENT_NOTIFICATION" const val PREFERENCE_EVENT_NOTIFICATION_INTERVAL = "KEY_EVENT_NOTIFICATION_INTERVAL" const val PREFERENCE_COURSE_NOTIFICATION = "KEY_COURSE_NOTIFICATION" const val PREFERENCE_COURSE_NOTIFICATION_INTERVAL = "KEY_COURSE_NOTIFICATION_INTERVAL" const val PREFERENCE_SYSTEM_NOTIFICATION = "KEY_SYSTEM_NOTIFICATION" const val PREFERENCE_ALLOW_WEEK_NUMBERS = "KEY_ALLOW_WEEK_NUMBERS" const val PREFERENCE_BACKUP_RESTORE = "KEY_BACKUP_RESTORE" const val PREFERENCE_BACKUP = "KEY_BACKUP" const val PREFERENCE_RESTORE = "KEY_RESTORE" const val PREFERENCE_USE_EXTERNAL_BROWSER = "KEY_USE_EXTERNAL_BROWSER" const val PREFERENCE_BATTERY_OPTIMIZATION = "KEY_BATTERY_OPTIMIZATION" // Preferences that are visible in AboutActivity const val PREFERENCE_REPORT_ISSUE = "KEY_REPORT_ISSUE" const val PREFERENCE_TRANSLATE = "KEY_TRANSLATE" const val PREFERENCE_NOTICES = "KEY_NOTICES" const val PREFERENCE_VERSION = "KEY_VERSION" // Preferences that are visible in NoticesActivity const val PREFERENCE_LIBRARIES = "KEY_LIBRARIES" const val PREFERENCE_NOTIFICATION_SOUND = "KEY_NOTIFICATION_SOUND" const val PREFERENCE_LAUNCHER_ICON = "KEY_LAUNCHER_ICON" const val PREFERENCE_UI_ICONS = "KEY_UI_ICONS" // Preferences that are not visible in anywhere and are only // used for remembering user choices in the UI const val PREFERENCE_NO_CONFIRM_IMPORT = "KEY_NO_CONFIRM_IMPORT" const val PREFERENCE_TASK_FILTER_OPTION = "KEY_TASKS_FILTER_OPTION" const val PREFERENCE_TASK_SORT_OPTION = "KEY_TASK_SORT_OPTION" const val PREFERENCE_TASK_SORT_DIRECTION = "KEY_TASK_SORT_DIRECTION" const val PREFERENCE_SUBJECT_FILTER_OPTION = "KEY_SUBJECT_FILTER_OPTION" const val PREFERENCE_SUBJECT_SORT_OPTION = "KEY_SUBJECT_SORT_OPTION" const val PREFERENCE_SUBJECT_SORT_DIRECTION = "KEY_SUBJECT_SORT_DIRECTION" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/views/RadioButtonCompat.kt ================================================ package com.isaiahvonrundstedt.fokus.components.views import android.content.Context import android.os.Build import android.util.AttributeSet import androidx.annotation.StyleRes import androidx.appcompat.widget.AppCompatRadioButton import com.isaiahvonrundstedt.fokus.R open class RadioButtonCompat @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = R.attr.radioButtonStyle ) : AppCompatRadioButton(context, attributeSet, defStyleAttr) { init { val typedArray = context.obtainStyledAttributes( attributeSet, R.styleable.RadioButtonCompat, defStyleAttr, 0 ) try { val textAppearance = typedArray.getResourceId( R.styleable.RadioButtonCompat_textAppearanceCompat, R.style.Fokus_TextAppearance_Body_Medium ) setTextAppearanceCompat(textAppearance) } catch (e: Exception) { } typedArray.recycle() } @Suppress("DEPRECATION") fun setTextAppearanceCompat(@StyleRes resId: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) this.setTextAppearance(resId) else this.setTextAppearance(context, resId) } override fun onAttachedToWindow() { super.onAttachedToWindow() setPaddingRelative(16, 0, 16, 0) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/views/ReactiveTextColorSwitch.kt ================================================ package com.isaiahvonrundstedt.fokus.components.views import android.content.Context import android.util.AttributeSet import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import com.google.android.material.switchmaterial.SwitchMaterial import com.isaiahvonrundstedt.fokus.R class ReactiveTextColorSwitch @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = R.attr.switchStyle ) : SwitchMaterial(context, attributeSet, defStyleAttr) { init { setSwitchTextAppearance(context, R.style.Fokus_TextAppearance_Label_Large) } override fun setChecked(checked: Boolean) { super.setChecked(checked) if (checked) setTextColorRes(R.color.color_primary_text) else setTextColorRes(R.color.color_secondary_text) } private fun setTextColorRes(@ColorRes resId: Int) { setTextColor(ContextCompat.getColor(context, resId)) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/components/views/TwoLineRadioButton.kt ================================================ package com.isaiahvonrundstedt.fokus.components.views import android.content.Context import android.os.Build import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.text.style.TextAppearanceSpan import android.util.AttributeSet import androidx.core.content.ContextCompat import com.isaiahvonrundstedt.fokus.R class TwoLineRadioButton @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = R.attr.radioButtonStyle ) : RadioButtonCompat(context, attributeSet, defStyleAttr) { private val titleSpan: TextAppearanceSpan private val subtitleSpan: TextAppearanceSpan private var titleTextColorSpan: ForegroundColorSpan private var subtitleTextColorSpan: ForegroundColorSpan var title: String = "" set(value) { field = value renderText() } var subtitle: String? = null set(value) { field = value renderText() } var titleTextColor: Int = ContextCompat.getColor(context, R.color.color_secondary_text) set(value) { field = value renderText() } var subtitleTextColor: Int = ContextCompat.getColor(context, R.color.color_secondary_text) set(value) { field = value renderText() } init { val typedArray = context.obtainStyledAttributes( attributeSet, R.styleable.TwoLineRadioButton, defStyleAttr, 0 ) try { title = typedArray.getString(R.styleable.TwoLineRadioButton_titleText) ?: "" subtitle = typedArray.getString(R.styleable.TwoLineRadioButton_subtitleText) titleTextColor = typedArray.getColor( R.styleable.TwoLineRadioButton_titleTextColor, titleTextColor ) subtitleTextColor = typedArray.getColor( R.styleable.TwoLineRadioButton_subtitleTextColor, subtitleTextColor ) val titleTextAppearance = typedArray.getResourceId( R.styleable.TwoLineRadioButton_titleTextAppearance, R.style.TextAppearance_AppCompat_Body1 ) titleSpan = TextAppearanceSpan(context, titleTextAppearance) titleTextColorSpan = ForegroundColorSpan(titleTextColor) val subtitleTextAppearance = typedArray.getResourceId( R.styleable.TwoLineRadioButton_subtitleTextAppearance, R.style.TextAppearance_AppCompat_Caption ) subtitleSpan = TextAppearanceSpan(context, subtitleTextAppearance) subtitleTextColorSpan = ForegroundColorSpan(subtitleTextColor) } finally { typedArray.recycle() } renderText() } private fun renderText() { titleTextColorSpan = ForegroundColorSpan(titleTextColor) subtitleTextColorSpan = ForegroundColorSpan(subtitleTextColor) val textToRender = if (subtitle.isNullOrEmpty()) title else "$title\n$subtitle" val builder = SpannableStringBuilder(textToRender).apply { setSpan( titleSpan, 0, title.length, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE ) setSpan( titleTextColorSpan, 0, title.length, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE ) subtitle?.also { if (it.isNotEmpty()) { setSpan( subtitleSpan, title.length, title.length + it.length + 1, SpannableString.SPAN_EXCLUSIVE_INCLUSIVE ) setSpan( subtitleTextColorSpan, title.length, title.length + it.length + 1, SpannableString.SPAN_EXCLUSIVE_INCLUSIVE ) } } } text = if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) builder.toString() else builder } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/AppDatabase.kt ================================================ package com.isaiahvonrundstedt.fokus.database import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.isaiahvonrundstedt.fokus.database.converter.ColorConverter import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.database.dao.* import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.task.Task import java.time.ZonedDateTime @Database( entities = [Subject::class, Task::class, Attachment::class, Log::class, Event::class, Schedule::class], version = AppDatabase.DATABASE_VERSION, exportSchema = true ) @TypeConverters(DateTimeConverter::class, ColorConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun subjects(): SubjectDAO abstract fun schedules(): ScheduleDAO abstract fun tasks(): TaskDAO abstract fun attachments(): AttachmentDAO abstract fun events(): EventDAO abstract fun logs(): LogDAO companion object { const val DATABASE_VERSION = 8 private const val DATABASE_NAME = "fokus" private var instance: AppDatabase? = null fun getInstance(context: Context): AppDatabase { if (instance == null) { synchronized(AppDatabase::class) { instance = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, DATABASE_NAME ) .addMigrations(*migrations) .build() } } return instance!! } private var migration_4_6 = object : Migration(4, 6) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { // schedules execSQL("CREATE TABLE IF NOT EXISTS `schedules_new` (`scheduleID` TEXT NOT NULL, `daysOfWeek` INTEGER NOT NULL, `weeksOfMonth` INTEGER NOT NULL DEFAULT 15,`startTime` TEXT, `endTime` TEXT, `subject` TEXT, PRIMARY KEY(`scheduleID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE CASCADE )") execSQL("ALTER TABLE schedules RENAME TO schedules_old") execSQL("ALTER TABLE schedules_new RENAME TO schedules") execSQL("INSERT INTO schedules (`scheduleID`, `daysOfWeek`, `startTime`, `endTime`, `subject`) SELECT * FROM `schedules_old`") execSQL("DROP TABLE schedules_old") execSQL("ALTER TABLE tasks ADD COLUMN `isTaskArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE events ADD COLUMN `isEventArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE subjects ADD COLUMN `isSubjectArchived` INTEGER NOT NULL DEFAULT 0") } } } private var migration_5_6 = object : Migration(5, 6) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE tasks ADD COLUMN `isTaskArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE events ADD COLUMN `isEventArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE subjects ADD COLUMN `isSubjectArchived` INTEGER NOT NULL DEFAULT 0") } } } private var migration_4_7 = object : Migration(4, 7) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { // schedules execSQL("CREATE TABLE IF NOT EXISTS `schedules_new` (`scheduleID` TEXT NOT NULL, `daysOfWeek` INTEGER NOT NULL, `weeksOfMonth` INTEGER NOT NULL DEFAULT 15,`startTime` TEXT, `endTime` TEXT, `subject` TEXT, PRIMARY KEY(`scheduleID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE CASCADE )") execSQL("ALTER TABLE schedules RENAME TO schedules_old") execSQL("ALTER TABLE schedules_new RENAME TO schedules") execSQL("INSERT INTO schedules (`scheduleID`, `daysOfWeek`, `startTime`, `endTime`, `subject`) SELECT * FROM `schedules_old`") execSQL("DROP TABLE schedules_old") execSQL("ALTER TABLE tasks ADD COLUMN `isTaskArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE events ADD COLUMN `isEventArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE subjects ADD COLUMN `isSubjectArchived` INTEGER NOT NULL DEFAULT 0") } var cursor = database.query("SELECT * FROM tasks") cursor.moveToFirst() val taskList = arrayListOf() while (cursor.moveToNext()) { val task = Task() task.taskID = cursor.getString(cursor.getColumnIndex("taskID")) task.name = cursor.getString(cursor.getColumnIndex("name")) task.notes = cursor.getString(cursor.getColumnIndex("notes")) task.subject = cursor.getString(cursor.getColumnIndex("subject")) task.isFinished = cursor.getInt(cursor.getColumnIndex("isFinished")) > 0 task.isImportant = cursor.getInt(cursor.getColumnIndex("isImportant")) > 0 task.isTaskArchived = cursor.getInt(cursor.getColumnIndex("isTaskArchived")) > 0 task.dueDate = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dueDate"))) task.dateAdded = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dateAdded"))) ?: ZonedDateTime.now() taskList.add(task) } cursor.close() database.execSQL("DELETE FROM tasks") taskList.forEach { val statement = database.compileStatement("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") with(statement) { bindString(1, it.taskID) bindString(2, it.name) bindString(3, it.notes) bindString(4, it.subject) bindLong(5, if (it.isImportant) 1 else 0) bindString(6, DateTimeConverter.fromZonedDateTime(it.dateAdded)) bindString(7, DateTimeConverter.fromZonedDateTime(it.dueDate)) bindLong(8, if (it.isFinished) 1 else 0) bindLong(9, if (it.isTaskArchived) 1 else 0) } statement.executeInsert() } taskList.clear() cursor = database.query("SELECT * FROM events") cursor.moveToFirst() val eventList = arrayListOf() while (cursor.moveToNext()) { val event = Event() event.eventID = cursor.getString(cursor.getColumnIndex("eventID")) event.name = cursor.getString(cursor.getColumnIndex("name")) event.notes = cursor.getString(cursor.getColumnIndex("notes")) event.schedule = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("schedule"))) event.location = cursor.getString(cursor.getColumnIndex("location")) event.isImportant = cursor.getInt(cursor.getColumnIndex("isImportant")) > 0 event.isEventArchived = cursor.getInt(cursor.getColumnIndex("isEventArchived")) > 0 event.dateAdded = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dateAdded"))) ?: ZonedDateTime.now() } cursor.close() database.execSQL("DELETE FROM events") eventList.forEach { val statement = database.compileStatement("INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") with(statement) { bindString(1, it.eventID) bindString(2, it.name) bindString(3, it.location) bindString(4, it.subject) bindLong(5, if (it.isImportant) 1 else 0) bindLong(6, if (it.isEventArchived) 1 else 0) bindString(7, DateTimeConverter.fromZonedDateTime(it.schedule)) bindString(8, DateTimeConverter.fromZonedDateTime(it.dateAdded)) } statement.executeInsert() } eventList.clear() } } private var migration_5_7 = object : Migration(5, 7) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE tasks ADD COLUMN `isTaskArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE events ADD COLUMN `isEventArchived` INTEGER NOT NULL DEFAULT 0") execSQL("ALTER TABLE subjects ADD COLUMN `isSubjectArchived` INTEGER NOT NULL DEFAULT 0") execSQL("CREATE TABLE IF NOT EXISTS `events_new` (`eventID` TEXT NOT NULL, `name` TEXT, `notes` TEXT, `location` TEXT, `subject` TEXT, `isImportant` INTEGER NOT NULL, `isEventArchived` INTEGER NOT NULL, `schedule` TEXT, `dateAdded` TEXT, PRIMARY KEY(`eventID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE SET NULL )") execSQL("INSERT INTO `events_new` SELECT * FROM `events`") execSQL("DROP TABLE `events`") execSQL("ALTER TABLE `events_new` RENAME TO `events`") } var cursor = database.query("SELECT * FROM tasks") cursor.moveToFirst() val taskList = arrayListOf() while (cursor.moveToNext()) { val task = Task() task.taskID = cursor.getString(cursor.getColumnIndex("taskID")) task.name = cursor.getString(cursor.getColumnIndex("name")) task.notes = cursor.getString(cursor.getColumnIndex("notes")) task.subject = cursor.getString(cursor.getColumnIndex("subject")) task.isFinished = cursor.getInt(cursor.getColumnIndex("isFinished")) > 0 task.isImportant = cursor.getInt(cursor.getColumnIndex("isImportant")) > 0 task.isTaskArchived = cursor.getInt(cursor.getColumnIndex("isTaskArchived")) > 0 task.dueDate = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dueDate"))) task.dateAdded = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dateAdded"))) ?: ZonedDateTime.now() taskList.add(task) } cursor.close() database.execSQL("DELETE FROM tasks") taskList.forEach { val statement = database.compileStatement("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") with(statement) { bindString(1, it.taskID) bindString(2, it.name) bindString(3, it.notes) bindString(4, it.subject) bindLong(5, if (it.isImportant) 1 else 0) bindString(6, DateTimeConverter.fromZonedDateTime(it.dateAdded)) bindString(7, DateTimeConverter.fromZonedDateTime(it.dueDate)) bindLong(8, if (it.isFinished) 1 else 0) bindLong(9, if (it.isTaskArchived) 1 else 0) } statement.executeInsert() } taskList.clear() cursor = database.query("SELECT * FROM events") cursor.moveToFirst() val eventList = arrayListOf() while (cursor.moveToNext()) { val event = Event() event.eventID = cursor.getString(cursor.getColumnIndex("eventID")) event.name = cursor.getString(cursor.getColumnIndex("name")) event.notes = cursor.getString(cursor.getColumnIndex("notes")) event.schedule = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("schedule"))) event.location = cursor.getString(cursor.getColumnIndex("location")) event.isImportant = cursor.getInt(cursor.getColumnIndex("isImportant")) > 0 event.isEventArchived = cursor.getInt(cursor.getColumnIndex("isEventArchived")) > 0 event.dateAdded = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dateAdded"))) ?: ZonedDateTime.now() } cursor.close() database.execSQL("DELETE FROM events") eventList.forEach { val statement = database.compileStatement("INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") with(statement) { bindString(1, it.eventID) bindString(2, it.name) bindString(3, it.notes) bindString(4, it.location) bindString(5, it.subject) bindLong(6, if (it.isImportant) 1 else 0) bindLong(7, if (it.isEventArchived) 1 else 0) bindString(8, DateTimeConverter.fromZonedDateTime(it.schedule)) bindString(9, DateTimeConverter.fromZonedDateTime(it.dateAdded)) } statement.executeInsert() } eventList.clear() } } private var migration_6_7 = object : Migration(6, 7) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("CREATE TABLE IF NOT EXISTS `events_new` (`eventID` TEXT NOT NULL, `name` TEXT, `notes` TEXT, `location` TEXT, `subject` TEXT, `isImportant` INTEGER NOT NULL, `isEventArchived` INTEGER NOT NULL, `schedule` TEXT, `dateAdded` TEXT, PRIMARY KEY(`eventID`), FOREIGN KEY(`subject`) REFERENCES `subjects`(`subjectID`) ON UPDATE NO ACTION ON DELETE SET NULL )") execSQL("INSERT INTO `events_new` SELECT * FROM `events`") execSQL("DROP TABLE `events`") execSQL("ALTER TABLE `events_new` RENAME TO `events`") } var cursor = database.query("SELECT * FROM tasks") cursor.moveToFirst() val taskList = arrayListOf() while (cursor.moveToNext()) { val task = Task() task.taskID = cursor.getString(cursor.getColumnIndex("taskID")) task.name = cursor.getString(cursor.getColumnIndex("name")) task.notes = cursor.getString(cursor.getColumnIndex("notes")) task.subject = cursor.getString(cursor.getColumnIndex("subject")) task.isFinished = cursor.getInt(cursor.getColumnIndex("isFinished")) > 0 task.isImportant = cursor.getInt(cursor.getColumnIndex("isImportant")) > 0 task.isTaskArchived = cursor.getInt(cursor.getColumnIndex("isTaskArchived")) > 0 task.dueDate = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dueDate"))) task.dateAdded = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dateAdded"))) ?: ZonedDateTime.now() taskList.add(task) } cursor.close() database.execSQL("DELETE FROM tasks") taskList.forEach { val statement = database.compileStatement("INSERT INTO tasks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") with(statement) { bindString(1, it.taskID) bindString(2, it.name) bindString(3, it.notes) bindString(4, it.subject ?: "") bindLong(5, if (it.isImportant) 1 else 0) bindString(6, DateTimeConverter.fromZonedDateTime(it.dateAdded)) bindString(7, DateTimeConverter.fromZonedDateTime(it.dueDate)) bindLong(8, if (it.isFinished) 1 else 0) bindLong(9, if (it.isTaskArchived) 1 else 0) } statement.executeInsert() } taskList.clear() cursor = database.query("SELECT * FROM events") cursor.moveToFirst() val eventList = arrayListOf() while (cursor.moveToNext()) { val event = Event() event.eventID = cursor.getString(cursor.getColumnIndex("eventID")) event.name = cursor.getString(cursor.getColumnIndex("name")) event.notes = cursor.getString(cursor.getColumnIndex("notes")) event.schedule = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("schedule"))) event.location = cursor.getString(cursor.getColumnIndex("location")) event.isImportant = cursor.getInt(cursor.getColumnIndex("isImportant")) > 0 event.isEventArchived = cursor.getInt(cursor.getColumnIndex("isEventArchived")) > 0 event.dateAdded = DateTimeConverter.toZonedDateTime(cursor.getString(cursor.getColumnIndex("dateAdded"))) ?: ZonedDateTime.now() event.subject = cursor.getString(cursor.getColumnIndex("subject")) } cursor.close() database.execSQL("DELETE FROM events") eventList.forEach { val statement = database.compileStatement("INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") with(statement) { bindString(1, it.eventID) bindString(2, it.name) bindString(3, it.notes) bindString(4, it.location) bindString(5, it.subject ?: "") bindLong(6, if (it.isImportant) 1 else 0) bindLong(7, if (it.isEventArchived) 1 else 0) bindString(8, DateTimeConverter.fromZonedDateTime(it.schedule)) bindString(9, DateTimeConverter.fromZonedDateTime(it.dateAdded)) } statement.executeInsert() } eventList.clear() } } private var migration_7_8 = object : Migration(7, 8) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE subjects ADD COLUMN `instructor` TEXT") execSQL("ALTER TABLE schedules ADD COLUMN `classroom` TEXT") } } } private val migrations = arrayOf(migration_4_6, migration_5_6, migration_4_7, migration_5_7, migration_6_7, migration_7_8) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/converter/ColorConverter.kt ================================================ package com.isaiahvonrundstedt.fokus.database.converter import androidx.room.TypeConverter import com.isaiahvonrundstedt.fokus.features.subject.Subject class ColorConverter private constructor() { companion object { @JvmStatic @TypeConverter fun toColor(actualColor: Int): Subject.Tag? { return Subject.Tag.convertColorToTag(actualColor) } @JvmStatic @TypeConverter fun fromColor(tag: Subject.Tag): Int { return tag.color } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/converter/DateTimeConverter.kt ================================================ package com.isaiahvonrundstedt.fokus.database.converter import android.content.Context import android.text.format.DateFormat import androidx.room.TypeConverter import java.time.LocalDate import java.time.LocalTime import java.time.ZonedDateTime import java.time.format.DateTimeFormatter class DateTimeConverter private constructor() { companion object { private const val FORMAT_TIME_12_HOUR = "h:mm a" private const val FORMAT_TIME_24_HOUR = "H:mm" private const val FORMAT_DATE_TIME_12_HOUR_SHORT = "M-d, h:mm a" private const val FORMAT_DATE_TIME_24_HOUR_SHORT = "M-d, H:mm" private const val FORMAT_DATE_TIME_WITH_YEAR_12_HOUR_SHORT = "M-d-yy, h:mm a" private const val FORMAT_DATE_TIME_WITH_YEAR_24_HOUR_SHORT = "M-d-yy, H:mm" private const val FORMAT_DATE_TIME_12_HOUR = "MMMM d, h:mm a" private const val FORMAT_DATE_TIME_24_HOUR = "MMMM d, H:mm" private const val FORMAT_DATE_TIME_WITH_YEAR_12_HOUR = "MM d yyyy, h:mm a" private const val FORMAT_DATE_TIME_WITH_YEAR_24_HOUR = "Mm d yyyy, H:mm" fun getTimeFormatter(context: Context): DateTimeFormatter { val pattern = if (DateFormat.is24HourFormat(context)) FORMAT_TIME_24_HOUR else FORMAT_TIME_12_HOUR return DateTimeFormatter.ofPattern(pattern) } fun getDateTimeFormatter( context: Context, isShort: Boolean = false, withYear: Boolean = false ): DateTimeFormatter { val is24Hour: Boolean = DateFormat.is24HourFormat(context) val pattern = if (isShort) { if (withYear) { if (is24Hour) FORMAT_DATE_TIME_WITH_YEAR_24_HOUR_SHORT else FORMAT_DATE_TIME_WITH_YEAR_12_HOUR_SHORT } else { if (is24Hour) FORMAT_DATE_TIME_24_HOUR_SHORT else FORMAT_DATE_TIME_12_HOUR_SHORT } } else { if (withYear) { if (is24Hour) FORMAT_DATE_TIME_WITH_YEAR_24_HOUR else FORMAT_DATE_TIME_WITH_YEAR_12_HOUR } else { if (is24Hour) FORMAT_DATE_TIME_24_HOUR else FORMAT_DATE_TIME_12_HOUR } } return DateTimeFormatter.ofPattern(pattern) } @JvmStatic @TypeConverter fun fromLocalDate(localDate: LocalDate?): String? { return if (localDate != null) DateTimeFormatter.ISO_LOCAL_DATE.format(localDate) else null } @JvmStatic @TypeConverter fun toLocalDate(date: String?): LocalDate? { return if (date.isNullOrEmpty()) null else LocalDate.parse(date) } @JvmStatic @TypeConverter fun toZonedDateTime(time: String?): ZonedDateTime? { return if (time.isNullOrEmpty()) null else ZonedDateTime.parse(time) } @JvmStatic @TypeConverter fun fromZonedDateTime(zonedDateTime: ZonedDateTime?): String? { return if (zonedDateTime != null) DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(zonedDateTime) else null } @JvmStatic @TypeConverter fun toLocalTime(time: String?): LocalTime? { return if (time.isNullOrEmpty()) null else LocalTime.parse(time) } @JvmStatic @TypeConverter fun fromLocalTime(time: LocalTime?): String? { return DateTimeFormatter.ISO_LOCAL_TIME.format(time) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/dao/AttachmentDAO.kt ================================================ package com.isaiahvonrundstedt.fokus.database.dao import androidx.room.* import com.isaiahvonrundstedt.fokus.features.attachments.Attachment @Dao interface AttachmentDAO { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(attachment: Attachment) @Delete fun remove(attachment: Attachment) @Update fun update(attachment: Attachment) @Query("SELECT * FROM attachments") suspend fun fetch(): List @Query("DELETE FROM attachments WHERE task = :id") fun removeUsingTaskID(id: String) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/dao/EventDAO.kt ================================================ package com.isaiahvonrundstedt.fokus.database.dao import androidx.lifecycle.LiveData import androidx.room.* import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.event.EventPackage @Dao interface EventDAO { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(event: Event) @Delete fun remove(event: Event) @Update fun update(event: Event) @Query("SELECT eventID FROM events WHERE name = :event AND DATE(schedule) = DATE(:schedule) COLLATE NOCASE AND eventId != :eventId") suspend fun checkNameUniqueness(event: String?, schedule: String?, eventId: String?): List @Query("SELECT * FROM events") suspend fun fetch(): List @Query("SELECT * FROM events") suspend fun fetchPackage(): List @Transaction @Query("SELECT * FROM events LEFT JOIN subjects ON events.subject == subjects.subjectID WHERE isEventArchived = 0 ORDER BY schedule ASC") fun fetchLiveData(): LiveData> @Transaction @Query("SELECT * FROM events LEFT JOIN subjects ON events.subject == subjects.subjectID WHERE isEventArchived = 1 ORDER BY schedule ASC") fun fetchArchivedLiveData(): LiveData> } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/dao/LogDAO.kt ================================================ package com.isaiahvonrundstedt.fokus.database.dao import androidx.lifecycle.LiveData import androidx.room.* import com.isaiahvonrundstedt.fokus.features.log.Log @Dao interface LogDAO { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(log: Log) @Delete fun remove(log: Log) @Update fun update(log: Log) @Query("SELECT * FROM logs") fun fetchCore(): List @Query("DELETE FROM logs") suspend fun removeLogs() @Query("SELECT * FROM logs ORDER BY dateTimeTriggered ASC") fun fetch(): LiveData> } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/dao/ScheduleDAO.kt ================================================ package com.isaiahvonrundstedt.fokus.database.dao import androidx.room.* import com.isaiahvonrundstedt.fokus.features.schedule.Schedule @Dao interface ScheduleDAO { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(vararg schedule: Schedule) @Delete fun remove(schedule: Schedule) @Update fun update(schedule: Schedule) @Query("DELETE FROM schedules WHERE subject = :id") suspend fun removeUsingSubjectID(id: String) @Query("SELECT * FROM schedules") suspend fun fetch(): List @Query("SELECT * FROM schedules WHERE subject = :id") suspend fun fetchUsingID(id: String?): List } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/dao/SubjectDAO.kt ================================================ package com.isaiahvonrundstedt.fokus.database.dao import androidx.lifecycle.LiveData import androidx.room.* import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage @Dao interface SubjectDAO { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(subject: Subject) @Delete suspend fun remove(subject: Subject) @Update suspend fun update(subject: Subject) @Query("SELECT subjectID FROM subjects WHERE code = :code COLLATE NOCASE AND subjectID != :subjectId") suspend fun checkCodeUniqueness(code: String?, subjectId: String?): List @Query("SELECT * FROM subjects") suspend fun fetch(): List @Query("SELECT * FROM subjects") suspend fun fetchAsPackage(): List @Transaction @Query("SELECT * FROM subjects WHERE isSubjectArchived = 0 ORDER BY code ASC") fun fetchLiveData(): LiveData> @Transaction @Query("SELECT * FROM subjects WHERE isSubjectArchived = 1 ORDER BY code ASC") fun fetchArchivedLiveData(): LiveData> } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/dao/TaskDAO.kt ================================================ package com.isaiahvonrundstedt.fokus.database.dao import androidx.lifecycle.LiveData import androidx.room.* import com.isaiahvonrundstedt.fokus.features.task.Task import com.isaiahvonrundstedt.fokus.features.task.TaskPackage @Dao interface TaskDAO { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(task: Task) @Delete fun remove(task: Task) @Update fun update(task: Task) @Query("SELECT taskID FROM tasks WHERE name = :task COLLATE NOCASE AND taskID != :taskId") suspend fun checkNameUniqueness(task: String?, taskId: String?): List @Query("UPDATE tasks SET isFinished = :status WHERE taskID = :taskID") suspend fun setFinished(taskID: String, status: Int) @Query("SELECT * FROM tasks WHERE isFinished = 0 AND isTaskArchived = 0") suspend fun fetch(): List @Query("SELECT COUNT(*) FROM tasks WHERE isFinished = 0 AND isTaskArchived = 0") suspend fun fetchCount(): Int @Query("SELECT * FROM tasks WHERE isFinished = 0") suspend fun fetchAsPackage(): List @Transaction @Query("SELECT * FROM tasks LEFT JOIN subjects ON tasks.subject == subjects.subjectID WHERE isTaskArchived = 0 ORDER BY dueDate ASC") fun fetchLiveData(): LiveData> @Transaction @Query("SELECT * FROM tasks LEFT JOIN subjects ON tasks.subject == subjects.subjectID WHERE isTaskArchived = 1 ORDER BY dueDate ASC") fun fetchArchivedLiveData(): LiveData> } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/repository/EventRepository.kt ================================================ package com.isaiahvonrundstedt.fokus.database.repository import android.app.NotificationManager import android.content.Context import androidx.lifecycle.LiveData import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isAfterNow import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isBeforeNow import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.dao.EventDAO import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.event.EventPackage import com.isaiahvonrundstedt.fokus.features.event.widget.EventWidgetProvider import com.isaiahvonrundstedt.fokus.features.notifications.event.EventNotificationWorker import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class EventRepository @Inject constructor( @ApplicationContext private val context: Context, private val events: EventDAO, private val preferenceManager: PreferenceManager, private val workManager: WorkManager, private val notificationManager: NotificationManager ) { fun fetchLiveData(): LiveData> = events.fetchLiveData() fun fetchArchivedLiveData(): LiveData> = events.fetchArchivedLiveData() suspend fun checkNameUniqueness(name: String?, schedule: String?, eventId: String?): List = events.checkNameUniqueness(name, schedule, eventId) suspend fun fetch(): List = events.fetchPackage() suspend fun fetchCore(): List = events.fetch() suspend fun insert(event: Event) { events.insert(event) EventWidgetProvider.triggerRefresh(context) if (preferenceManager.eventReminder && event.schedule?.isAfterNow() == true) { val data = BaseWorker.convertEventToData(event) val request = OneTimeWorkRequest.Builder(EventNotificationWorker::class.java) .setInputData(data) .addTag(event.eventID) .build() workManager.enqueueUniqueWork( event.eventID, ExistingWorkPolicy.REPLACE, request ) } } suspend fun remove(event: Event) { events.remove(event) EventWidgetProvider.triggerRefresh(context) if (event.isImportant) notificationManager.cancel(event.eventID, BaseWorker.NOTIFICATION_ID_EVENT) workManager.cancelUniqueWork(event.eventID) } suspend fun update(event: Event) { events.update(event) EventWidgetProvider.triggerRefresh(context) if (event.schedule?.isBeforeNow() == true || !event.isImportant) notificationManager.cancel(event.eventID, BaseWorker.NOTIFICATION_ID_EVENT) if (preferenceManager.eventReminder && event.schedule?.isAfterNow() == true) { val data = BaseWorker.convertEventToData(event) val request = OneTimeWorkRequest.Builder(EventNotificationWorker::class.java) .setInputData(data) .addTag(event.eventID) .build() workManager.enqueueUniqueWork( event.eventID, ExistingWorkPolicy.REPLACE, request ) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/repository/LogRepository.kt ================================================ package com.isaiahvonrundstedt.fokus.database.repository import androidx.lifecycle.LiveData import com.isaiahvonrundstedt.fokus.database.dao.LogDAO import com.isaiahvonrundstedt.fokus.features.log.Log import javax.inject.Inject class LogRepository @Inject constructor(private val logs: LogDAO) { fun fetch(): LiveData> = logs.fetch() suspend fun insert(log: Log) { logs.insert(log) } suspend fun remove(log: Log) { logs.remove(log) } suspend fun update(log: Log) { logs.update(log) } suspend fun removeLogs() { logs.removeLogs() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/repository/SubjectRepository.kt ================================================ package com.isaiahvonrundstedt.fokus.database.repository import android.content.Context import androidx.lifecycle.LiveData import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.dao.ScheduleDAO import com.isaiahvonrundstedt.fokus.database.dao.SubjectDAO import com.isaiahvonrundstedt.fokus.features.notifications.subject.ClassNotificationWorker import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import com.isaiahvonrundstedt.fokus.features.subject.widget.SubjectWidgetProvider import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class SubjectRepository @Inject constructor( @ApplicationContext private val context: Context, private val subjects: SubjectDAO, private val schedules: ScheduleDAO, private val preferenceManager: PreferenceManager, private val workManager: WorkManager ) { fun fetchLiveData(): LiveData> = subjects.fetchLiveData() fun fetchArchivedLiveData(): LiveData> = subjects.fetchArchivedLiveData() suspend fun fetch(): List = subjects.fetchAsPackage() suspend fun checkCodeExists(code: String?, subjectId: String?): List = subjects.checkCodeUniqueness(code, subjectId) suspend fun insert(subject: Subject, scheduleList: List) { subjects.insert(subject) scheduleList.forEach { schedules.insert(it) } SubjectWidgetProvider.triggerRefresh(context) if (preferenceManager.subjectReminder) { scheduleList.forEach { it.subject = subject.code val data = BaseWorker.convertScheduleToData(it) val request = OneTimeWorkRequest.Builder(ClassNotificationWorker::class.java) .setInputData(data) .addTag(subject.subjectID) .addTag(it.scheduleID) .build() workManager.enqueueUniqueWork( it.scheduleID, ExistingWorkPolicy.REPLACE, request ) } } } suspend fun remove(subject: Subject) { subjects.remove(subject) SubjectWidgetProvider.triggerRefresh(context) workManager.cancelAllWorkByTag(subject.subjectID) } suspend fun update(subject: Subject, scheduleList: List = emptyList()) { subjects.update(subject) schedules.removeUsingSubjectID(subject.subjectID) if (scheduleList.isNotEmpty()) scheduleList.forEach { schedules.insert(it) } SubjectWidgetProvider.triggerRefresh(context) if (preferenceManager.subjectReminder) { scheduleList.forEach { workManager.cancelAllWorkByTag(it.scheduleID) it.subject = subject.code val data = BaseWorker.convertScheduleToData(it) val request = OneTimeWorkRequest.Builder(ClassNotificationWorker::class.java) .setInputData(data) .addTag(subject.subjectID) .addTag(it.scheduleID) .build() workManager.enqueueUniqueWork( it.scheduleID, ExistingWorkPolicy.REPLACE, request ) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/database/repository/TaskRepository.kt ================================================ package com.isaiahvonrundstedt.fokus.database.repository import android.app.NotificationManager import android.content.Context import androidx.lifecycle.LiveData import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isBeforeNow import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.dao.AttachmentDAO import com.isaiahvonrundstedt.fokus.database.dao.TaskDAO import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.notifications.task.TaskNotificationWorker import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import com.isaiahvonrundstedt.fokus.features.task.Task import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import com.isaiahvonrundstedt.fokus.features.task.widget.TaskWidgetProvider import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class TaskRepository @Inject constructor( @ApplicationContext private val context: Context, private val tasks: TaskDAO, private val attachments: AttachmentDAO, private val preferenceManager: PreferenceManager, private val workManager: WorkManager, private val notificationManager: NotificationManager ) { fun fetchLiveData(): LiveData> = tasks.fetchLiveData() fun fetchArchived(): LiveData> = tasks.fetchArchivedLiveData() suspend fun fetchCore(): List = tasks.fetch() suspend fun fetchCount(): Int = tasks.fetchCount() suspend fun fetchAsPackage(): List = tasks.fetchAsPackage() suspend fun checkNameUniqueness(name: String?, id: String?): List = tasks.checkNameUniqueness(name, id) suspend fun insert(task: Task, attachmentList: List = emptyList()) { tasks.insert(task) if (attachmentList.isNotEmpty()) attachmentList.forEach { attachments.insert(it) } TaskWidgetProvider.triggerRefresh(context) // Check if notifications for tasks are turned on and check if // the task is not finished, then schedule a notification if (preferenceManager.taskReminder && !task.isFinished && task.isDueDateInFuture() && task.hasDueDate() ) { val data = BaseWorker.convertTaskToData(task) val request = OneTimeWorkRequest.Builder(TaskNotificationWorker::class.java) .setInputData(data) .addTag(task.taskID) .build() workManager.enqueueUniqueWork( task.taskID, ExistingWorkPolicy.REPLACE, request ) } } suspend fun remove(task: Task) { tasks.remove(task) TaskWidgetProvider.triggerRefresh(context) // If the task is important, its persistent notification should // be canceled. if (task.isImportant) notificationManager.cancel(task.taskID, BaseWorker.NOTIFICATION_ID_TASK) workManager.cancelUniqueWork(task.taskID) } suspend fun update(task: Task, attachmentList: List = emptyList()) { tasks.update(task) attachments.removeUsingTaskID(task.taskID) if (attachmentList.isNotEmpty()) attachmentList.forEach { attachments.insert(it) } TaskWidgetProvider.triggerRefresh(context) // If we have a persistent notification, // we should dismiss it when the user updates // the task to finish if (task.isFinished || !task.isImportant || task.dueDate?.isBeforeNow() == true) notificationManager.cancel(task.taskID, BaseWorker.NOTIFICATION_ID_TASK) // Check if notifications for tasks is turned on and if the task // is not finished then reschedule the notification from // WorkManager if (preferenceManager.taskReminder && !task.isFinished) { workManager.cancelUniqueWork(task.taskID) val data = BaseWorker.convertTaskToData(task) val request = OneTimeWorkRequest.Builder(TaskNotificationWorker::class.java) .setInputData(data) .addTag(task.taskID) .build() workManager.enqueueUniqueWork( task.taskID, ExistingWorkPolicy.REPLACE, request ) } } suspend fun setFinished(taskID: String, status: Boolean) { if (status) tasks.setFinished(taskID, 1) else tasks.setFinished(taskID, 0) } suspend fun addAttachment(attachment: Attachment) { attachments.insert(attachment) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/about/AboutFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.about import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.browser.customtabs.CustomTabsIntent import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController import androidx.preference.Preference import com.isaiahvonrundstedt.fokus.BuildConfig import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.databinding.FragmentAboutBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BasePreference class AboutFragment : BaseFragment() { private var _binding: FragmentAboutBinding? = null private var controller: NavController? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentAboutBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_about) setupNavigation(this, R.drawable.ic_outline_arrow_back_24) { controller?.navigateUp() } } } override fun onStart() { super.onStart() controller = findNavController() } companion object { const val ABOUT_ISSUE_URL = "https://github.com/icabetong/fokus-android/issues/new" const val ABOUT_RELEASE_URL = "https://github.com/icabetong/fokus-android/releases" const val ABOUT_DEVELOPER_EMAIL = "isaiahcollins_02@live.com" class AboutFragment : BasePreference() { private var controller: NavController? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.xml_about_main, rootKey) } override fun onStart() { super.onStart() controller = Navigation.findNavController(requireActivity(), R.id.navigationHostFragment) findPreference(PreferenceManager.PREFERENCE_NOTICES) ?.setOnPreferenceClickListener { controller?.navigate(R.id.navigation_notices) true } findPreference(PreferenceManager.PREFERENCE_TRANSLATE) ?.setOnPreferenceClickListener { val intent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse("mailto:${ABOUT_DEVELOPER_EMAIL}") } if (intent.resolveActivity(requireContext().packageManager) != null) startActivity(intent) true } findPreference(PreferenceManager.PREFERENCE_REPORT_ISSUE) ?.setOnPreferenceClickListener { CustomTabsIntent.Builder().build() .launchUrl(requireContext(), Uri.parse(ABOUT_ISSUE_URL)) true } setPreferenceSummary(PreferenceManager.PREFERENCE_VERSION, BuildConfig.VERSION_NAME) findPreference(PreferenceManager.PREFERENCE_VERSION) ?.setOnPreferenceClickListener { CustomTabsIntent.Builder().build() .launchUrl(requireContext(), Uri.parse(ABOUT_RELEASE_URL)) true } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/about/LibrariesFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.about import android.os.Build import android.os.Bundle import android.text.Html import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.databinding.FragmentLibrariesBinding import com.isaiahvonrundstedt.fokus.databinding.LayoutItemLibraryBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library class LibrariesFragment : BaseFragment() { private var _binding: FragmentLibrariesBinding? = null private var controller: NavController? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentLibrariesBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.recyclerView)) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_open_source_licenses) setupNavigation(this, R.drawable.ic_outline_arrow_back_24) { controller?.navigateUp() } } with(binding.recyclerView) { layoutManager = LinearLayoutManager(context) adapter = LibraryAdapter(Libs(context).libraries) } } class LibraryAdapter(private val itemList: List) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LibraryViewHolder { val rowView: View = LayoutInflater.from(parent.context).inflate( R.layout.layout_item_library, parent, false ) return LibraryViewHolder(rowView) } override fun onBindViewHolder(holder: LibraryViewHolder, position: Int) { holder.onBind(itemList[position]) } override fun getItemCount(): Int = itemList.size class LibraryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val binding = LayoutItemLibraryBinding.bind(itemView) @Suppress("DEPRECATION") fun onBind(library: Library) { binding.nameTextView.text = library.libraryName binding.versionTextView.text = library.libraryVersion if (library.author.isNotEmpty()) binding.authorTextView.text = library.author else binding.authorTextView.visibility = View.GONE val license = library.licenses?.firstOrNull() if (license != null) { binding.licenseNameTextView.text = license.licenseName binding.licenseDescriptionTextView.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) Html.fromHtml( license.licenseShortDescription, Html.FROM_HTML_MODE_COMPACT ) else Html.fromHtml(license.licenseShortDescription) } else { binding.licenseNameTextView.visibility = View.GONE binding.licenseDescriptionTextView.visibility = View.GONE } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/about/NoticesFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.about import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.browser.customtabs.CustomTabsIntent import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.preference.Preference import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.databinding.FragmentNoticesBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BasePreference class NoticesFragment : BaseFragment() { private var _binding: FragmentNoticesBinding? = null private var controller: NavController? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentNoticesBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_notices) setupNavigation(this, R.drawable.ic_outline_arrow_back_24) { controller?.navigateUp() } } } companion object { const val URL_LAUNCHER_ICON_BASE = "https://flaticon.com/authors/freepik" const val URL_NOTIFICATION_SOUND = "https://www.zapsplat.com/music/ui-alert-prompt-warm-wooden-mallet-style-notification-tone-generic-11/" const val URL_USER_INTERFACE_ICONS = "https://heroicons.dev" class NoticesFragment : BasePreference() { private var controller: NavController? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.xml_about_notices, rootKey) } override fun onStart() { super.onStart() controller = Navigation.findNavController(requireView()) findPreference(PreferenceManager.PREFERENCE_LIBRARIES) ?.setOnPreferenceClickListener { controller?.navigate(R.id.navigation_libraries) true } findPreference(PreferenceManager.PREFERENCE_NOTIFICATION_SOUND) ?.setOnPreferenceClickListener { CustomTabsIntent.Builder().build() .launchUrl(requireContext(), Uri.parse(URL_NOTIFICATION_SOUND)) true } findPreference(PreferenceManager.PREFERENCE_LAUNCHER_ICON) ?.setOnPreferenceClickListener { CustomTabsIntent.Builder().build() .launchUrl(requireContext(), Uri.parse(URL_LAUNCHER_ICON_BASE)) true } findPreference(PreferenceManager.PREFERENCE_UI_ICONS) ?.setOnPreferenceClickListener { CustomTabsIntent.Builder().build() .launchUrl(requireContext(), Uri.parse(URL_USER_INTERFACE_ICONS)) true } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/attachments/Attachment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.attachments import android.content.Context import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.recyclerview.widget.DiffUtil import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.task.Task import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import okio.buffer import okio.sink import java.io.File import java.net.URLConnection import java.time.ZonedDateTime import java.util.* @Parcelize @JsonClass(generateAdapter = true) @Entity( tableName = "attachments", foreignKeys = [ForeignKey( entity = Task::class, parentColumns = arrayOf("taskID"), childColumns = arrayOf("task"), onDelete = ForeignKey.CASCADE ) ] ) data class Attachment @JvmOverloads constructor( @PrimaryKey var attachmentID: String = UUID.randomUUID().toString(), var name: String? = null, var target: String? = null, var task: String = "", var type: Int = 0, @TypeConverters(DateTimeConverter::class) var dateAttached: ZonedDateTime? = ZonedDateTime.now() ) : Parcelable { @DrawableRes fun getIconResource(): Int { return when (type) { TYPE_WEBSITE_LINK -> R.drawable.ic_outline_link_24 else -> R.drawable.ic_outline_file_open_24 } } companion object { const val TYPE_UNKNOWN = 0 const val TYPE_CONTENT_URI = 1 const val TYPE_IMPORTED_FILE = 2 const val TYPE_WEBSITE_LINK = 3 val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Attachment, newItem: Attachment): Boolean { return oldItem.attachmentID == newItem.attachmentID } override fun areContentsTheSame(oldItem: Attachment, newItem: Attachment): Boolean { return oldItem == newItem } } fun isImage(path: String): Boolean { val mimeType = URLConnection.guessContentTypeFromName(path) return mimeType != null && mimeType.startsWith("image") } fun generateId(): String { return UUID.randomUUID().toString() } fun getTargetDirectory(context: Context?): File { return File(context?.getExternalFilesDir(null), Streamable.DIRECTORY_ATTACHMENTS) } fun toJsonFile( items: List, destination: File, name: String = Streamable.FILE_NAME_ATTACHMENT ): File { return File(destination, name).apply { this.sink().buffer().use { JsonDataStreamer.encodeToJson(items, Attachment::class.java)?.also { json -> it.write(json.toByteArray()) } } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/attachments/AttachmentOptionSheet.kt ================================================ package com.isaiahvonrundstedt.fokus.features.attachments import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.fragment.app.setFragmentResult import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.databinding.LayoutSheetOptionsBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseBottomSheet import com.isaiahvonrundstedt.fokus.features.shared.adapters.MenuAdapter class AttachmentOptionSheet(manager: FragmentManager) : BaseBottomSheet(manager), MenuAdapter.MenuItemListener { private var _binding: LayoutSheetOptionsBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = LayoutSheetOptionsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.menuTitleView.text = getString(R.string.dialog_new_attachment) with(binding.recyclerView) { layoutManager = LinearLayoutManager(context) adapter = MenuAdapter( activity, R.menu.menu_attachment, this@AttachmentOptionSheet ) } } override fun onItemSelected(id: Int) { setFragmentResult( REQUEST_KEY, bundleOf( EXTRA_OPTION to id ) ) } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val REQUEST_KEY = "request:attachment" const val EXTRA_OPTION = "extra:option" fun show(manager: FragmentManager) { AttachmentOptionSheet(manager) .show() } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/attachments/attach/AttachToTaskActivity.kt ================================================ package com.isaiahvonrundstedt.fokus.features.attachments.attach import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.components.extensions.android.createToast import com.isaiahvonrundstedt.fokus.databinding.ActivityAttachToTaskBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseActivity import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class AttachToTaskActivity : BaseActivity(), BaseAdapter.SelectListener { private lateinit var binding: ActivityAttachToTaskBinding private val attachAdapter = AttachToTaskAdapter(this) private val viewModel: AttachToTaskViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityAttachToTaskBinding.inflate(layoutInflater) setContentView(binding.root) setPersistentActionBar(binding.appBarLayout.toolbar) setToolbarTitle(R.string.sharing_attach_to_task) intent?.also { viewModel.subject = it.getStringExtra(Intent.EXTRA_SUBJECT) viewModel.url = it.getStringExtra(Intent.EXTRA_TEXT) } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = attachAdapter } } override fun onStart() { super.onStart() binding.titleView.text = viewModel.subject binding.summaryView.text = viewModel.url viewModel.tasks.observe(this) { attachAdapter.submitList(it) } } override fun onItemSelected(t: T) { if (t is TaskPackage) { viewModel.addAttachment(t.task.taskID) createToast(R.string.feedback_attachment_added) finish() } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/attachments/attach/AttachToTaskAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.attachments.attach import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.isaiahvonrundstedt.fokus.databinding.LayoutItemTaskSendBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.task.TaskPackage class AttachToTaskAdapter(private val selectListener: SelectListener) : BaseAdapter(TaskPackage.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder { val binding = LayoutItemTaskSendBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return TaskViewHolder(binding.root, selectListener) } override fun onBindViewHolder(holder: TaskViewHolder, position: Int) { holder.onBind(getItem(position)) } class TaskViewHolder( itemView: View, private val selectListener: SelectListener ) : BaseAdapter.BaseViewHolder(itemView) { private val binding = LayoutItemTaskSendBinding.bind(itemView) override fun onBind(data: T) { if (data is TaskPackage) { binding.titleView.text = data.task.name binding.summaryView.text = data.task.formatDueDate(itemView.context) binding.addButton.setOnClickListener { selectListener.onItemSelected(data) } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/attachments/attach/AttachToTaskViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.attachments.attach import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class AttachToTaskViewModel @Inject constructor( private val repository: TaskRepository ) : ViewModel() { val tasks: LiveData> = repository.fetchLiveData() val isEmpty: LiveData = Transformations.map(tasks) { it.isNullOrEmpty() } var attachment = Attachment() var subject: String? = null set(value) { field = value attachment.name = value } var url: String? = null set(value) { field = value attachment.target = value } init { attachment.type = Attachment.TYPE_WEBSITE_LINK } fun addAttachment(taskID: String) = viewModelScope.launch { attachment.task = taskID repository.addAttachment(attachment) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/core/activities/MainActivity.kt ================================================ package com.isaiahvonrundstedt.fokus.features.core.activities import android.Manifest import android.app.NotificationManager import android.content.Context import android.os.Build import android.os.Bundle import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf import androidx.navigation.NavController import androidx.navigation.findNavController import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.components.extensions.android.getParcelableListExtra import com.isaiahvonrundstedt.fokus.components.utils.NotificationChannelManager import com.isaiahvonrundstedt.fokus.databinding.ActivityMainBinding import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.event.editor.EventEditorFragment import com.isaiahvonrundstedt.fokus.features.notifications.task.TaskReminderWorker import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseActivity import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.editor.SubjectEditorFragment import com.isaiahvonrundstedt.fokus.features.task.Task import com.isaiahvonrundstedt.fokus.features.task.editor.TaskEditorFragment import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : BaseActivity() { private var controller: NavController? = null private lateinit var binding: ActivityMainBinding private lateinit var requestPermissionLauncher: ActivityResultLauncher private val notificationManager: NotificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) controller = findNavController(R.id.navigationHostFragment) requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { enabled -> if (enabled) createSnackbar(R.string.notifications_are_now_enabled_for_this_app) else createSnackbar(R.string.notifications_are_not_enabled_for_this_app) } intent?.also { intent -> when (intent.action) { ACTION_WIDGET_TASK -> { val task: Task? = intent.getParcelableExtra(EXTRA_TASK) val subject: Subject? = intent.getParcelableExtra(EXTRA_SUBJECT) val attachments: List? = intent.getParcelableListExtra(EXTRA_ATTACHMENTS) val args = bundleOf( TaskEditorFragment.EXTRA_TASK to task?.let { Task.toBundle(it) }, TaskEditorFragment.EXTRA_SUBJECT to subject?.let { Subject.toBundle(it) }, TaskEditorFragment.EXTRA_ATTACHMENTS to attachments ) controller?.navigate(R.id.navigation_editor_task, args) } ACTION_WIDGET_EVENT -> { val event: Event? = intent.getParcelableExtra(EXTRA_EVENT) val subject: Subject? = intent.getParcelableExtra(EXTRA_SUBJECT) val args = bundleOf( EventEditorFragment.EXTRA_EVENT to event?.let { Event.toBundle(it) }, EventEditorFragment.EXTRA_SUBJECT to subject?.let { Subject.toBundle(it) } ) controller?.navigate(R.id.navigation_editor_event, args) } ACTION_WIDGET_SUBJECT -> { val subject: Subject? = intent.getParcelableExtra(EXTRA_SUBJECT) val schedules: List? = intent.getParcelableListExtra(EXTRA_SCHEDULES) val args = bundleOf( SubjectEditorFragment.EXTRA_SUBJECT to subject?.let { Subject.toBundle(it) }, SubjectEditorFragment.EXTRA_SCHEDULE to schedules ) controller?.navigate(R.id.navigation_editor_subject, args) } ACTION_SHORTCUT_TASK -> { controller?.navigate(R.id.navigation_editor_task) } ACTION_SHORTCUT_EVENT -> { controller?.navigate(R.id.navigation_editor_event) } ACTION_SHORTCUT_SUBJECT -> { controller?.navigate(R.id.navigation_editor_subject) } ACTION_NAVIGATION_TASK -> { //controller?.navigate(R.id.navigation_tasks) } ACTION_NAVIGATION_EVENT -> { //controller?.navigate(R.id.navigation_events) } ACTION_NAVIGATION_SUBJECT -> { //controller?.navigate(R.id.navigation_subjects) } } } TaskReminderWorker.reschedule(this.applicationContext) } override fun onStart() { super.onStart() if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU && !notificationManager.areNotificationsEnabled()) { requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { with(NotificationChannelManager(this)) { register( NotificationChannelManager.CHANNEL_ID_GENERIC, NotificationManager.IMPORTANCE_DEFAULT ) register( NotificationChannelManager.CHANNEL_ID_TASK, groupID = NotificationChannelManager.CHANNEL_GROUP_ID_REMINDERS ) register( NotificationChannelManager.CHANNEL_ID_EVENT, groupID = NotificationChannelManager.CHANNEL_GROUP_ID_REMINDERS ) register( NotificationChannelManager.CHANNEL_ID_CLASS, groupID = NotificationChannelManager.CHANNEL_GROUP_ID_REMINDERS ) } } } } override fun onSupportNavigateUp(): Boolean = controller?.navigateUp() ?: false companion object { const val ACTION_SHORTCUT_TASK = "action:shortcut:task" const val ACTION_SHORTCUT_EVENT = "action:shortcut:event" const val ACTION_SHORTCUT_SUBJECT = "action:shortcut:subject" const val ACTION_WIDGET_TASK = "action:widget:task" const val ACTION_WIDGET_EVENT = "action:widget:event" const val ACTION_WIDGET_SUBJECT = "action:widget:subject" const val ACTION_NAVIGATION_TASK = "action:navigation:task" const val ACTION_NAVIGATION_EVENT = "action:navigation:event" const val ACTION_NAVIGATION_SUBJECT = "action:navigation:subject" const val EXTRA_TASK = "extra:task" const val EXTRA_EVENT = "extra:event" const val EXTRA_SUBJECT = "extra:subject" const val EXTRA_ATTACHMENTS = "extra:attachments" const val EXTRA_SCHEDULES = "extra:schedules" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/core/fragment/RootFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.core.fragment import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.GravityCompat import androidx.core.view.doOnPreDraw import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.databinding.FragmentRootBinding import com.isaiahvonrundstedt.fokus.databinding.LayoutNavigationHeaderBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import java.time.LocalTime class RootFragment: BaseFragment() { private var _binding: FragmentRootBinding? = null private var _headerBinding: LayoutNavigationHeaderBinding? = null private var controller: NavController? = null private var mainController: NavController? = null private val binding get() = _binding!! private val headerBinding get() = _headerBinding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentRootBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null _headerBinding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mainController = requireActivity().supportFragmentManager .findFragmentById(R.id.navigationHostFragment)?.findNavController() /** * Get this fragment's NavController * to control what fragment will show up * depending on what directions is on the * NavigationViewModel */ controller = (childFragmentManager.findFragmentById(R.id.fragmentContainerView) as? NavHostFragment)?.navController if (binding.navigationView.headerCount > 0) { _headerBinding = LayoutNavigationHeaderBinding .bind(binding.navigationView.getHeaderView(0)) headerBinding.menuTitleView.text = when (LocalTime.now().hour) { in 0..6 -> getString(R.string.greeting_default) in 7..12 -> getString(R.string.greeting_morning) in 13..18 -> getString(R.string.greeting_afternoon) in 19..23 -> getString(R.string.greeting_evening) else -> getString(R.string.greeting_default) } } postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } } override fun onResume() { super.onResume() binding.navigationView.setNavigationItemSelectedListener { binding.navigationView.setCheckedItem(it.itemId) try { controller?.navigate(it.itemId) binding.drawerLayout.closeDrawer(GravityCompat.START) } catch (exception: Exception) { mainController?.navigate(it.itemId) } true } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/core/worker/ActionWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.core.worker import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.components.service.NotificationActionService import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject // This worker's primary function perform the action // at is triggered in the fokus such as 'Mark as Finished' @HiltWorker class ActionWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val repository: TaskRepository ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val action = inputData.getString(NotificationActionService.EXTRA_ACTION) val taskID = inputData.getString(NotificationActionService.EXTRA_TASK_ID) if (action.isNullOrBlank() || taskID.isNullOrBlank()) return Result.success() if (action == NotificationActionService.ACTION_FINISHED) repository.setFinished(taskID, true) return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/Event.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event import android.content.Context import android.os.Bundle import android.os.Parcelable import android.text.format.DateFormat import androidx.core.os.bundleOf import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isToday import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isTomorrow import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isYesterday import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import okio.buffer import okio.sink import java.io.File import java.io.InputStream import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.* @Parcelize @JsonClass(generateAdapter = true) @Entity( tableName = "events", foreignKeys = [ ForeignKey( entity = Subject::class, parentColumns = arrayOf("subjectID"), childColumns = arrayOf("subject"), onDelete = ForeignKey.SET_NULL ) ] ) data class Event @JvmOverloads constructor( @PrimaryKey var eventID: String = UUID.randomUUID().toString(), var name: String? = null, var notes: String? = null, var location: String? = null, var subject: String? = null, var isImportant: Boolean = false, var isEventArchived: Boolean = false, @TypeConverters(DateTimeConverter::class) var schedule: ZonedDateTime? = null, @TypeConverters(DateTimeConverter::class) var dateAdded: ZonedDateTime? = ZonedDateTime.now() ) : Parcelable, Streamable { fun isToday(): Boolean { return schedule?.isToday() == true } fun formatScheduleTime(context: Context): String? { return schedule?.format(DateTimeConverter.getTimeFormatter(context)) } fun formatSchedule(context: Context): String? { val datePattern = if (DateFormat.is24HourFormat(context)) FORMAT_DATE_WITH_WEEKDAY_24_HOUR else FORMAT_DATE_WITH_WEEKDAY_12_HOUR return if (schedule?.isToday() == true) String.format( context.getString(R.string.today_at), schedule?.format(DateTimeConverter.getTimeFormatter(context)) ) else if (schedule?.isYesterday() == true) String.format( context.getString(R.string.yesterday_at), schedule?.format(DateTimeConverter.getTimeFormatter(context)) ) else if (schedule?.isTomorrow() == true) String.format( context.getString(R.string.tomorrow_at), schedule?.format(DateTimeConverter.getTimeFormatter(context)) ) else schedule?.format(DateTimeFormatter.ofPattern(datePattern)) } override fun toJsonString(): String? = JsonDataStreamer.encodeToJson(this, Event::class.java) override fun toJsonFile(destination: File, name: String): File { return File(destination, name).apply { this.sink().buffer().use { toJsonString()?.also { json -> it.write(json.toByteArray()) } } } } override fun fromInputStream(inputStream: InputStream) { JsonDataStreamer.decodeOnceFromJson(inputStream, Event::class.java)?.also { eventID = it.eventID name = it.name location = it.location schedule = it.schedule dateAdded = it.dateAdded isImportant = it.isImportant notes = it.notes subject = it.subject } } companion object { const val EXTRA_ID = "extra:id" const val EXTRA_NAME = "extra:name" const val EXTRA_NOTES = "extra:notes" const val EXTRA_SCHEDULE = "extra:schedule" const val EXTRA_LOCATION = "extra:location" const val EXTRA_SUBJECT = "extra:subject" const val EXTRA_IS_IMPORTANT = "extra:important" const val EXTRA_IS_ARCHIVED = "extra:archived" const val EXTRA_DATE_ADDED = "extra:added" const val FORMAT_DATE_WITH_WEEKDAY_12_HOUR = "EEE - MMMM d, h:mm a" const val FORMAT_DATE_WITH_WEEKDAY_24_HOUR = "EEE - MMMM d, H:mm" fun toBundle(event: Event): Bundle { return bundleOf( EXTRA_ID to event.eventID, EXTRA_NAME to event.name, EXTRA_LOCATION to event.location, EXTRA_SCHEDULE to event.schedule, EXTRA_NOTES to event.notes, EXTRA_IS_IMPORTANT to event.isImportant, EXTRA_IS_ARCHIVED to event.isEventArchived, EXTRA_DATE_ADDED to event.dateAdded, EXTRA_SUBJECT to event.subject ) } fun fromBundle(bundle: Bundle): Event? { if (!bundle.containsKey(EXTRA_ID)) return null return Event( eventID = bundle.getString(EXTRA_ID)!!, name = bundle.getString(EXTRA_NAME), location = bundle.getString(EXTRA_LOCATION), schedule = bundle.getSerializable(EXTRA_SCHEDULE) as? ZonedDateTime, notes = bundle.getString(EXTRA_NOTES), isImportant = bundle.getBoolean(EXTRA_IS_IMPORTANT), isEventArchived = bundle.getBoolean(EXTRA_IS_ARCHIVED), subject = bundle.getString(EXTRA_SUBJECT) ) } fun fromInputStream(inputStream: InputStream): Event { return Event().apply { this.fromInputStream(inputStream) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/EventAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.ItemTouchHelper import com.isaiahvonrundstedt.fokus.components.extensions.android.getCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.interfaces.Swipeable import com.isaiahvonrundstedt.fokus.databinding.LayoutItemEventBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment class EventAdapter( private val actionListener: ActionListener, private val archiveListener: ArchiveListener ) : BaseAdapter(EventPackage.DIFF_CALLBACK), Swipeable { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder { val binding = LayoutItemEventBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return EventViewHolder(binding.root, actionListener) } override fun onBindViewHolder(holder: EventViewHolder, position: Int) { holder.onBind(getItem(position)) } override fun onSwipe(position: Int, direction: Int) { when (direction) { ItemTouchHelper.START -> actionListener.onActionPerformed( getItem(position), ActionListener.Action.DELETE, null ) ItemTouchHelper.END -> archiveListener.onItemArchive(getItem(position)) } } class EventViewHolder( itemView: View, private val actionListener: ActionListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemEventBinding.bind(itemView) override fun onBind(data: T) { if (data is EventPackage) { with(data.event) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + data.event.eventID binding.locationView.text = location binding.nameView.text = name binding.timeView.text = formatScheduleTime(binding.root.context) } if (data.subject != null) { with(binding.subjectView) { text = data.subject?.code setCompoundDrawableAtStart( data.subject?.tintDrawable( getCompoundDrawableAtStart() ) ) } } else binding.subjectView.isVisible = false binding.root.setOnClickListener { actionListener.onActionPerformed(data, ActionListener.Action.SELECT, it) } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/EventFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.core.os.bundleOf import androidx.core.view.children import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.components.custom.ItemSwipeCallback import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.components.extensions.android.isDark import com.isaiahvonrundstedt.fokus.components.extensions.android.setTextColorFromResource import com.isaiahvonrundstedt.fokus.databinding.FragmentEventBinding import com.isaiahvonrundstedt.fokus.databinding.LayoutCalendarDayBinding import com.isaiahvonrundstedt.fokus.features.event.editor.EventEditorFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.kizitonwose.calendarview.model.CalendarDay import com.kizitonwose.calendarview.model.CalendarMonth import com.kizitonwose.calendarview.model.DayOwner import com.kizitonwose.calendarview.ui.DayBinder import com.kizitonwose.calendarview.ui.MonthHeaderFooterBinder import com.kizitonwose.calendarview.ui.ViewContainer import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu import java.time.DayOfWeek import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.temporal.WeekFields import java.util.* @AndroidEntryPoint class EventFragment : BaseFragment(), BaseAdapter.ActionListener, BaseAdapter.ArchiveListener { private var daysOfWeek: Array = emptyArray() private var _binding: FragmentEventBinding? = null private var controller: NavController? = null private val binding get() = _binding!! private val eventAdapter = EventAdapter(this, this) private val monthYearFormatter = DateTimeFormatter.ofPattern("MMMM yyyy") private val dateFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy") private val viewModel: EventViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentEventBinding.inflate(inflater) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.actionButton.transitionName = TRANSITION_ELEMENT_ROOT setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.containerLayout), binding.actionButton) with(binding.appBarLayout.toolbar) { title = viewModel.currentMonth.format(monthYearFormatter) menu?.clear() inflateMenu(R.menu.menu_events) overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) setupNavigation(this) } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = eventAdapter ItemTouchHelper(ItemSwipeCallback(context, eventAdapter)) .attachToRecyclerView(this) } postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } daysOfWeek = daysOfWeekFromLocale() binding.calendarView.apply { setup( viewModel.startMonth, viewModel.endMonth, daysOfWeek.first() ) scrollToMonth(viewModel.currentMonth) } if (savedInstanceState == null) binding.calendarView.post { setCurrentDate(viewModel.today) } } override fun onStart() { super.onStart() /** * Get the NavController here so * that it doesn't crash when * the host activity is recreated. */ controller = Navigation.findNavController(requireActivity(), R.id.navigationHostFragment) class DayViewContainer(view: View) : ViewContainer(view) { lateinit var day: CalendarDay val textView: TextView = LayoutCalendarDayBinding.bind(view).calendarDayView val dotView: View = LayoutCalendarDayBinding.bind(view).calendarDotView init { view.setOnClickListener { if (day.owner == DayOwner.THIS_MONTH) setCurrentDate(day.date) } } } viewModel.events.observe(viewLifecycleOwner) { eventAdapter.submitList(it) } viewModel.eventsEmpty.observe(viewLifecycleOwner) { binding.emptyView.isVisible = it } class MonthViewContainer(view: View) : ViewContainer(view) { val headerLayout: LinearLayout = view.findViewById(R.id.headerLayout) } binding.calendarView.dayBinder = object : DayBinder { override fun create(view: View): DayViewContainer = DayViewContainer(view) override fun bind(container: DayViewContainer, day: CalendarDay) { container.day = day bindToCalendar(day, container.textView, container.dotView) } } binding.calendarView.monthHeaderBinder = object : MonthHeaderFooterBinder { override fun create(view: View): MonthViewContainer = MonthViewContainer(view) override fun bind(container: MonthViewContainer, month: CalendarMonth) { val headerLayout = container.headerLayout if (container.headerLayout.tag == null) { headerLayout.tag = month.yearMonth headerLayout.children.map { it as TextView } .forEachIndexed { index, textView -> textView.text = daysOfWeek[index].name.first().toString() } } } } binding.calendarView.monthScrollListener = { setCurrentDate(it.yearMonth.atDay(1)) binding.appBarLayout.toolbar.title = it.yearMonth.format(monthYearFormatter) // Check if the user is nearing the end of the month list. // Then continually add more months so that the user // can scroll infinitely. if (it.yearMonth.minusMonths(2) == viewModel.startMonth) { // The user is two months away from the starting month in the CalendarView // we'll need to add more months at the start viewModel.startMonth = viewModel.startMonth.minusMonths(2) binding.calendarView.updateMonthRange(startMonth = viewModel.startMonth) } else if (it.yearMonth.plusMonths(2) == viewModel.endMonth) { // The user is two months away from the ending month in the CalendarView // we'll need to add more months at the end viewModel.endMonth = viewModel.endMonth.plusMonths(2) binding.calendarView.updateMonthRange(endMonth = viewModel.endMonth) } } // Observe dates with events then rebind the // dayBinder to the Calendar. viewModel.dates.observe(viewLifecycleOwner) { dates -> binding.calendarView.dayBinder = object : DayBinder { override fun create(view: View): DayViewContainer { return DayViewContainer(view) } override fun bind(container: DayViewContainer, day: CalendarDay) { container.day = day bindToCalendar(day, container.textView, container.dotView, dates) } } } } override fun onResume() { super.onResume() binding.calendarView.scrollToMonth(viewModel.currentMonth) binding.calendarView.scrollToDate(viewModel.today) setCurrentDate(viewModel.today) binding.actionButton.setOnClickListener { it.transitionName = TRANSITION_ELEMENT_ROOT controller?.navigate( R.id.navigation_editor_event, null, null, FragmentNavigatorExtras(it to TRANSITION_ELEMENT_ROOT) ) } } override fun onItemArchive(t: T) { if (t is EventPackage) { t.event.isEventArchived = true viewModel.update(t.event) } } override fun onActionPerformed( t: T, action: BaseAdapter.ActionListener.Action, container: View? ) { if (t is EventPackage) { when (action) { // Show up the editorUI and pass the extra BaseAdapter.ActionListener.Action.SELECT -> { val transitionName = TRANSITION_ELEMENT_ROOT + t.event.eventID val args = bundleOf( EventEditorFragment.EXTRA_EVENT to Event.toBundle(t.event), EventEditorFragment.EXTRA_SUBJECT to t.subject?.let { Subject.toBundle(it) } ) controller?.navigate( R.id.navigation_editor_event, args, null, FragmentNavigatorExtras(container!! to transitionName) ) } // Item has been swiped, notify database for deletion BaseAdapter.ActionListener.Action.DELETE -> { viewModel.remove(t.event) createSnackbar(R.string.feedback_event_removed, binding.recyclerView).run { setAction(R.string.button_undo) { viewModel.insert(t.event) } } } } } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_archived -> { controller?.navigate(R.id.navigation_archived_event) } } return true } private fun bindToCalendar( day: CalendarDay, textView: TextView, view: View, dates: List = emptyList() ) { textView.text = day.date.dayOfMonth.toString() if (day.owner == DayOwner.THIS_MONTH) { when (day.date) { viewModel.today -> { val color = if (requireContext().isDark()) android.R.color.system_accent1_50 else android.R.color.system_accent1_500 textView.setTextColorFromResource(color) textView.setBackgroundResource(R.drawable.shape_calendar_current_day) view.isVisible = false } viewModel.selectedDate -> { textView.setTextColorFromResource(R.color.theme_on_primary_container) textView.setBackgroundResource(R.drawable.shape_calendar_selected_day) view.isVisible = false } else -> { textView.setTextColorFromResource(R.color.color_primary_text) textView.background = null view.isVisible = dates.contains(day.date) } } } else { textView.setTextColorFromResource(R.color.color_secondary_text) view.isVisible = false } } private fun setCurrentDate(date: LocalDate) { if (viewModel.selectedDate != date) { val oldDate = viewModel.selectedDate viewModel.selectedDate = date binding.calendarView.notifyDateChanged(oldDate) binding.calendarView.notifyDateChanged(date) } binding.currentDateTextView.text = date.format(dateFormatter) } private fun daysOfWeekFromLocale(): Array { val firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek var daysOfWeek = DayOfWeek.values() // Order `daysOfWeek` array so that firstDayOfWeek is at index 0. // Only necessary if firstDayOfWeek != DayOfWeek.MONDAY which has ordinal 0. if (firstDayOfWeek != DayOfWeek.MONDAY) { val rhs = daysOfWeek.sliceArray(firstDayOfWeek.ordinal..daysOfWeek.indices.last) val lhs = daysOfWeek.sliceArray(0 until firstDayOfWeek.ordinal) daysOfWeek = rhs + lhs } return daysOfWeek } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/EventPackage.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event import android.os.Parcelable import androidx.recyclerview.widget.DiffUtil import androidx.room.Embedded import com.isaiahvonrundstedt.fokus.features.subject.Subject import kotlinx.android.parcel.Parcelize /** * Data class used for the presentation of * events and subject in the UI */ @Parcelize data class EventPackage( @Embedded var event: Event, @Embedded var subject: Subject? = null ) : Parcelable { companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: EventPackage, newItem: EventPackage): Boolean { return oldItem.event.eventID == newItem.event.eventID } override fun areContentsTheSame(oldItem: EventPackage, newItem: EventPackage): Boolean { return oldItem == newItem } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/EventViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event import androidx.lifecycle.* import com.isaiahvonrundstedt.fokus.database.repository.EventRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import java.time.LocalDate import java.time.YearMonth import javax.inject.Inject @HiltViewModel class EventViewModel @Inject constructor( private val repository: EventRepository ) : ViewModel() { private val _events: LiveData> = repository.fetchLiveData() val dates: MediatorLiveData> = MediatorLiveData() val events: MediatorLiveData> = MediatorLiveData() val eventsEmpty: LiveData = Transformations.map(events) { it.isNullOrEmpty() } val today: LocalDate get() = LocalDate.now() val currentMonth: YearMonth get() = YearMonth.now() var selectedDate: LocalDate = today set(value) { field = value events.value = _events.value?.filter { it.event.schedule!!.toLocalDate() == selectedDate } } var startMonth: YearMonth = currentMonth.minusMonths(1) var endMonth: YearMonth = currentMonth.plusMonths(1) init { events.addSource(_events) { items -> events.value = items.filter { it.event.schedule!!.toLocalDate() == selectedDate } } dates.addSource(_events) { items -> dates.value = items.map { it.event.schedule!!.toLocalDate() }.distinct() } } fun insert(event: Event) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.insert(event) } fun remove(event: Event) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.remove(event) } fun update(event: Event) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.update(event) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/archived/ArchivedEventAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.archived import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.isaiahvonrundstedt.fokus.components.extensions.android.getCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.databinding.LayoutItemArchivedEventBinding import com.isaiahvonrundstedt.fokus.features.event.EventPackage import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment class ArchivedEventAdapter(private val listener: SelectListener) : BaseAdapter(EventPackage.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArchivedEventViewHolder { val binding = LayoutItemArchivedEventBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ArchivedEventViewHolder(binding.root, listener) } override fun onBindViewHolder(holder: ArchivedEventViewHolder, position: Int) { holder.onBind(getItem(position)) } class ArchivedEventViewHolder( itemView: View, private val listener: SelectListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemArchivedEventBinding.bind(itemView) override fun onBind(data: T) { if (data is EventPackage) { with(data.event) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + eventID binding.locationView.text = location binding.nameView.text = name binding.timeView.text = formatScheduleTime(binding.root.context) } binding.root.setOnClickListener { listener.onItemSelected(data) } if (data.subject != null) { with(binding.subjectView) { text = data.subject?.code setCompoundDrawableAtStart( data.subject?.tintDrawable( getCompoundDrawableAtStart() ) ) } } else binding.subjectView.isVisible = false } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/archived/ArchivedEventFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.archived import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.recyclerview.widget.LinearLayoutManager import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.databinding.FragmentArchivedEventBinding import com.isaiahvonrundstedt.fokus.features.event.EventPackage import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ArchivedEventFragment : BaseFragment(), BaseAdapter.SelectListener { private var _binding: FragmentArchivedEventBinding? = null private var controller: NavController? = null private val archivedEventAdapter = ArchivedEventAdapter(this) private val viewModel: ArchivedEventViewModel by viewModels() private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentArchivedEventBinding.inflate(layoutInflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.recyclerView, binding.emptyView)) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_archives) setNavigationIcon(R.drawable.ic_outline_arrow_back_24) setNavigationOnClickListener { controller?.navigateUp() } } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = archivedEventAdapter } } override fun onItemSelected(t: T) { if (t is EventPackage) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.dialog_confirm_unarchive_title) message(R.string.dialog_confirm_unarchive_summary) positiveButton { viewModel.removeFromArchive(t) } negativeButton(R.string.button_cancel) } } } override fun onStart() { super.onStart() viewModel.items.observe(viewLifecycleOwner) { archivedEventAdapter.submitList(it) } viewModel.isEmpty.observe(viewLifecycleOwner) { binding.emptyView.isVisible = it } } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/archived/ArchivedEventViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.archived import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.repository.EventRepository import com.isaiahvonrundstedt.fokus.features.event.EventPackage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArchivedEventViewModel @Inject constructor( private val eventRepository: EventRepository ) : ViewModel() { val items: LiveData> = eventRepository.fetchArchivedLiveData() val isEmpty: LiveData = Transformations.map(items) { it.isEmpty() } fun removeFromArchive(eventPackage: EventPackage) = viewModelScope.launch { eventPackage.event.isEventArchived = false eventRepository.update(eventPackage.event) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/editor/EventEditorContainer.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.editor import android.os.Bundle import com.isaiahvonrundstedt.fokus.databinding.ActivityContainerEventBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseActivity import dagger.hilt.android.AndroidEntryPoint /** * This activity acts as a container * for the editor fragment. This is * used when needing to show the * editor ui without needing a fragment * transaction. */ @AndroidEntryPoint class EventEditorContainer : BaseActivity() { private lateinit var binding: ActivityContainerEventBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityContainerEventBinding.inflate(layoutInflater) setContentView(binding.root) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/editor/EventEditorFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.editor import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle import android.text.format.DateFormat.is24HourFormat import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.AppCompatTextView import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.core.view.isVisible import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.viewModels import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.NavController import androidx.navigation.Navigation import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.datetime.dateTimePicker import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.isaiahvonrundstedt.fokus.Fokus import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.components.extensions.android.removeCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setTextColorFromResource import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toCalendar import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toZonedDateTime import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.service.DataExporterService import com.isaiahvonrundstedt.fokus.components.service.DataImporterService import com.isaiahvonrundstedt.fokus.components.views.TwoLineRadioButton import com.isaiahvonrundstedt.fokus.databinding.FragmentEditorEventBinding import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.event.EventPackage import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.schedule.picker.SchedulePickerSheet import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseEditorFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import com.isaiahvonrundstedt.fokus.features.subject.picker.SubjectPickerFragment import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu import java.io.File import java.time.ZoneId import java.time.ZonedDateTime @AndroidEntryPoint class EventEditorFragment : BaseEditorFragment(), FragmentResultListener { private var _binding: FragmentEditorEventBinding? = null private var controller: NavController? = null private var requestKey = REQUEST_KEY_INSERT private val viewModel: EventEditorViewModel by viewModels() private val binding get() = _binding!! private lateinit var exportLauncher: ActivityResultLauncher private lateinit var importLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedElementEnterTransition = buildContainerTransform() sharedElementReturnTransition = buildContainerTransform() exportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { context?.startService(Intent(context, DataExporterService::class.java).apply { this.data = it.data?.data action = DataExporterService.ACTION_EXPORT_EVENT putExtra(DataExporterService.EXTRA_EXPORT_SOURCE, viewModel.getEvent()) }) } importLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { context?.startService(Intent(context, DataImporterService::class.java).apply { this.data = it.data?.data action = DataImporterService.ACTION_IMPORT_EVENT }) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentEditorEventBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.root.transitionName = TRANSITION_ELEMENT_ROOT setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.contentView), binding.actionButton) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { inflateMenu(R.menu.menu_editor) setNavigationOnClickListener { if (controller?.graph?.id == R.id.navigation_container_event) requireActivity().finish() else controller?.navigateUp() } overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) } arguments?.getBundle(EXTRA_EVENT)?.also { requestKey = REQUEST_KEY_UPDATE Event.fromBundle(it)?.also { event -> viewModel.setEvent(event) binding.root.transitionName = TRANSITION_ELEMENT_ROOT + event.eventID binding.priorityCard.isVisible = event.isImportant } } arguments?.getBundle(EXTRA_SUBJECT)?.also { viewModel.setSubject(Subject.fromBundle(it)) } registerForFragmentResult( arrayOf( SubjectPickerFragment.REQUEST_KEY_PICK, SchedulePickerSheet.REQUEST_KEY ), this ) } override fun onStart() { super.onStart() LocalBroadcastManager.getInstance(requireContext()) .registerReceiver(receiver, IntentFilter(BaseService.ACTION_SERVICE_BROADCAST)) if (requestKey == REQUEST_KEY_UPDATE) { binding.eventNameTextInput.setText(viewModel.getName()) binding.locationTextInput.setText(viewModel.getLocation()) binding.notesTextInput.setText(viewModel.getNotes()) } viewModel.event.observe(viewLifecycleOwner) { if (requestKey == REQUEST_KEY_UPDATE && it != null) { with(it) { binding.scheduleTextView.text = formatSchedule(requireContext()) binding.prioritySwitch.isChecked = isImportant } } } viewModel.subject.observe(viewLifecycleOwner) { binding.removeButton.isVisible = it != null binding.scheduleTextView.isVisible = it == null binding.dateTimeRadioGroup.isVisible = it != null if (it != null) { with(binding.subjectTextView) { text = it.code setTextColorFromResource(R.color.color_primary_text) ContextCompat.getDrawable(context, R.drawable.shape_color_holder) ?.also { shape -> this.setCompoundDrawableAtStart(it.tintDrawable(shape)) } } if (viewModel.schedules.isNotEmpty()) { with(binding.customDateTimeRadio) { isChecked = true titleTextColor = ContextCompat.getColor( context, R.color.color_primary_text ) subtitle = viewModel.getEvent()?.formatSchedule(context) } } } else { with(binding.subjectTextView) { removeCompoundDrawableAtStart() setText(R.string.field_subject) setTextColorFromResource(R.color.color_secondary_text) } if (viewModel.schedules.isNotEmpty()) { with(binding.scheduleTextView) { text = viewModel.getEvent()?.formatSchedule(context) setTextColorFromResource(R.color.color_primary_text) } } } } viewModel.isNameTaken.observe(this) { binding.eventNameTextInputLayout.error = if (it) getString(R.string.feedback_event_name_exists) else null } binding.eventNameTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setName(v.text.toString()) } } binding.locationTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setLocation(v.text.toString()) } } binding.notesTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setNotes(v.text.toString()) } } binding.prioritySwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setImportant(isChecked) binding.priorityCard.isVisible = isChecked } binding.scheduleTextView.setOnClickListener { v -> MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) dateTimePicker( requireFutureDateTime = true, currentDateTime = viewModel.getSchedule()?.toCalendar(), show24HoursView = is24HourFormat(requireContext()) ) { _, datetime -> viewModel.setSchedule(datetime.toZonedDateTime()) } positiveButton(R.string.button_done) { if (v is AppCompatTextView) { v.text = viewModel.getEvent()?.formatSchedule(context) v.setTextColorFromResource(R.color.color_primary_text) } } } } binding.subjectTextView.setOnClickListener { SubjectPickerFragment(childFragmentManager) .show() } binding.removeButton.setOnClickListener { binding.subjectTextView.startAnimation(animation) viewModel.setSubject(null) } binding.dateTimeRadioGroup.setOnCheckedChangeListener { radioGroup, _ -> for (v: View in radioGroup.children) { if (v is TwoLineRadioButton && !v.isChecked) { v.titleTextColor = ContextCompat.getColor( v.context, R.color.color_secondary_text ) v.subtitle = null } } } binding.inNextMeetingRadio.setOnClickListener { viewModel.setNextMeetingForDueDate() with(binding.inNextMeetingRadio) { titleTextColor = ContextCompat.getColor(context, R.color.color_primary_text) subtitle = viewModel.getEvent()?.formatSchedule(context) } } binding.pickDateTimeRadio.setOnClickListener { hideKeyboardFromCurrentFocus(requireView()) SchedulePickerSheet .show(viewModel.schedules, childFragmentManager) } binding.customDateTimeRadio.setOnClickListener { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) dateTimePicker( requireFutureDateTime = true, currentDateTime = viewModel.getSchedule()?.toCalendar(), show24HoursView = is24HourFormat(requireContext()) ) { _, datetime -> viewModel.setSchedule( ZonedDateTime.ofInstant( datetime.toInstant(), ZoneId.systemDefault() ) ) } positiveButton(R.string.button_done) { with(binding.customDateTimeRadio) { titleTextColor = ContextCompat.getColor( context, R.color.color_primary_text ) subtitle = viewModel.getEvent()?.formatSchedule(context) } } negativeButton { binding.customDateTimeRadio.isChecked = false } } } binding.actionButton.setOnClickListener { viewModel.setName(binding.eventNameTextInput.text.toString()) viewModel.setLocation(binding.locationTextInput.text.toString()) // Conditions to check if the fields are null or blank // then if resulted true, show a feedback then direct // user focus to the field and stop code execution. if (viewModel.getName()?.isEmpty() == true) { createSnackbar(R.string.feedback_event_empty_name, binding.root) binding.eventNameTextInput.requestFocus() return@setOnClickListener } if (viewModel.getLocation()?.isEmpty() == true) { createSnackbar(R.string.feedback_event_empty_location, binding.root) binding.locationTextInput.requestFocus() return@setOnClickListener } if (viewModel.getSchedule() == null) { createSnackbar(R.string.feedback_event_empty_schedule, binding.root) binding.scheduleTextView.performClick() return@setOnClickListener } viewModel.setImportant(binding.prioritySwitch.isChecked) if (requestKey == REQUEST_KEY_INSERT) viewModel.insert() else viewModel.update() if (controller?.graph?.id == R.id.navigation_container_event) requireActivity().finish() else controller?.navigateUp() } } override fun onFragmentResult(requestKey: String, result: Bundle) { when (requestKey) { SubjectPickerFragment.REQUEST_KEY_PICK -> { result.getParcelable(SubjectPickerFragment.EXTRA_SELECTED_SUBJECT)?.let { viewModel.setSubject(it.subject) viewModel.schedules = it.schedules } } SchedulePickerSheet.REQUEST_KEY -> { result.getParcelable(SchedulePickerSheet.EXTRA_SCHEDULE)?.also { viewModel.setClassScheduleAsDueDate(it) with(binding.pickDateTimeRadio) { titleTextColor = ContextCompat.getColor( context, R.color.color_primary_text ) subtitle = viewModel.getEvent()?.formatSchedule(context) } } } } } override fun onStop() { super.onStop() /** * Check if the soft keyboard is visible * then hide it before the user leaves * the fragment */ hideKeyboardFromCurrentFocus(requireView()) } override fun onDestroy() { super.onDestroy() _binding = null LocalBroadcastManager.getInstance(requireContext()) .unregisterReceiver(receiver) } private var receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == BaseService.ACTION_SERVICE_BROADCAST) { when (intent.getStringExtra(BaseService.EXTRA_BROADCAST_STATUS)) { DataExporterService.BROADCAST_EXPORT_ONGOING -> { createSnackbar( R.string.feedback_export_ongoing, binding.root, Snackbar.LENGTH_INDEFINITE ) } DataExporterService.BROADCAST_EXPORT_COMPLETED -> { createSnackbar(R.string.feedback_export_completed, binding.root) intent.getStringExtra(BaseService.EXTRA_BROADCAST_DATA)?.also { val uri = Fokus.obtainUriForFile(requireContext(), File(it)) startActivity( ShareCompat.IntentBuilder.from(requireActivity()) .addStream(uri) .setType(Streamable.MIME_TYPE_ZIP) .setChooserTitle(R.string.dialog_send_to) .intent ) } } DataExporterService.BROADCAST_EXPORT_FAILED -> { createSnackbar(R.string.feedback_export_failed, binding.root) } DataImporterService.BROADCAST_IMPORT_ONGOING -> { createSnackbar(R.string.feedback_import_ongoing, binding.root) } DataImporterService.BROADCAST_IMPORT_COMPLETED -> { createSnackbar(R.string.feedback_import_completed, binding.root) intent.getParcelableExtra(BaseService.EXTRA_BROADCAST_DATA) ?.also { viewModel.setEvent(it.event) } } DataImporterService.BROADCAST_IMPORT_FAILED -> { createSnackbar(R.string.feedback_import_failed, binding.root) } } } } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_export -> { val fileName = getSharingName() if (fileName == null) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_done) { dismiss() } } return false } /** * Make the user specify where to save * the exported file */ exportLauncher.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, fileName) type = Streamable.MIME_TYPE_ZIP }) } R.id.action_share -> { val fileName = getSharingName() if (fileName == null) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_done) { dismiss() } } return false } /** * We need first to export the data as a raw file * then pass it onto the system sharing component */ context?.startService(Intent(context, DataExporterService::class.java).apply { action = DataExporterService.ACTION_EXPORT_EVENT putExtra( DataExporterService.EXTRA_EXPORT_SOURCE, viewModel.getEvent() ) }) } R.id.action_import -> { val chooser = Intent.createChooser(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { type = Streamable.MIME_TYPE_ZIP }, getString(R.string.dialog_select_file_import)) importLauncher.launch(chooser) } } return true } override fun onSaveInstanceState(outState: Bundle) { with(outState) { putParcelable(EXTRA_EVENT, viewModel.getEvent()) putParcelable(EXTRA_SUBJECT, viewModel.getSubject()) } super.onSaveInstanceState(outState) } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) savedInstanceState?.run { viewModel.setEvent(getParcelable(EXTRA_EVENT)) viewModel.setSubject(getParcelable(EXTRA_SUBJECT)) } } private fun getSharingName(): String? { return when (requestKey) { REQUEST_KEY_INSERT -> { if (viewModel.getName()?.isEmpty() == true || viewModel.getLocation() ?.isEmpty() == true || viewModel.getSchedule() == null ) { MaterialDialog(requireContext()).show { title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_dismiss) { dismiss() } } return null } viewModel.getName() ?: Streamable.ARCHIVE_NAME_GENERIC } REQUEST_KEY_UPDATE -> { viewModel.getName() ?: Streamable.ARCHIVE_NAME_GENERIC } else -> null } } companion object { const val REQUEST_KEY_INSERT = "request:insert" const val REQUEST_KEY_UPDATE = "request:update" const val EXTRA_EVENT = "extra:event" const val EXTRA_SUBJECT = "extra:subject" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/editor/EventEditorViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.editor import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.database.dao.ScheduleDAO import com.isaiahvonrundstedt.fokus.database.repository.EventRepository import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.Subject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import java.time.LocalDate import java.time.ZonedDateTime import javax.inject.Inject @HiltViewModel class EventEditorViewModel @Inject constructor( private val scheduleDao: ScheduleDAO, private val repository: EventRepository ) : ViewModel() { private val _event: MutableLiveData = MutableLiveData(Event()) private val _subject: MutableLiveData = MutableLiveData(null) private val _isNameTaken: MutableLiveData = MutableLiveData(false) val event: LiveData = _event val subject: LiveData = _subject val isNameTaken: LiveData = _isNameTaken var schedules: List = emptyList() fun getEvent(): Event? { return event.value } fun setEvent(event: Event?) { _event.value = event } fun getSubject(): Subject? { return subject.value } fun setSubject(subject: Subject?) { _subject.value = subject if (subject != null) { fetchSchedulesFromDatabase(subject.subjectID) setEventSubjectID(subject.subjectID) } else { schedules = emptyList() setEventSubjectID(null) } } fun checkNameUniqueness(name: String? = null, schedule: ZonedDateTime? = null) = viewModelScope.launch { val result = repository.checkNameUniqueness( name ?: getName(), DateTimeConverter.fromLocalDate( schedule?.toLocalDate() ?: getSchedule()?.toLocalDate() ), getEvent()?.eventID ) _isNameTaken.value = result.isNotEmpty() } fun getID(): String? { return getEvent()?.eventID } fun getName(): String? { return getEvent()?.name } fun setName(name: String?) { // Check if the same value is being set if (name == getName()) return val event = getEvent() event?.name = name setEvent(event) } fun getSchedule(): ZonedDateTime? { return getEvent()?.schedule } fun setSchedule(schedule: ZonedDateTime?) { val event = getEvent() event?.schedule = schedule setEvent(event) } fun getLocation(): String? { return getEvent()?.location } fun setLocation(location: String?) { // Check if the same value is being set if (location == getLocation()) return val event = getEvent() event?.location = location setEvent(event) } fun getEventSubjectID(): String? { return getEvent()?.eventID } fun setEventSubjectID(id: String?) { val event = getEvent() event?.subject = id setEvent(event) } fun getImportant(): Boolean { return getEvent()?.isImportant == true } fun setImportant(isImportant: Boolean) { val event = getEvent() event?.isImportant = isImportant setEvent(event) } fun getNotes(): String? { return getEvent()?.notes } fun setNotes(notes: String) { if (notes == getNotes()) return val event = getEvent() event?.notes = notes setEvent(event) } fun setNextMeetingForDueDate() { setSchedule(getNextMeetingForSchedule()) } fun setClassScheduleAsDueDate(schedule: Schedule) { setSchedule(schedule.startTime?.let { Schedule.getNearestDateTime( schedule.daysOfWeek, it ) }) } private fun getNextMeetingForSchedule(): ZonedDateTime? { val currentDate = LocalDate.now() val individualDates = mutableListOf() // Create new instances of Schedule // with individual day of week values schedules.forEach { it.parseDaysOfWeek().forEach { day -> val newSchedule = Schedule( startTime = it.startTime, endTime = it.endTime ) newSchedule.daysOfWeek = day individualDates.add(newSchedule) } } // Map the schedule instances to // a ZonedDateTime instance val dates = individualDates.map { it.startTime?.let { time -> Schedule.getNearestDateTime( it.daysOfWeek, time ) } } if (dates.isEmpty()) return null return dates.singleOrNull { currentDate.isAfter(it?.toLocalDate()) || currentDate.isEqual(it?.toLocalDate()) } ?: dates[0] } private fun fetchSchedulesFromDatabase(id: String) = viewModelScope.launch { schedules = scheduleDao.fetchUsingID(id) } fun insert() = viewModelScope.launch(Dispatchers.IO + NonCancellable) { getEvent()?.let { repository.insert(it) } } fun update() = viewModelScope.launch(Dispatchers.IO + NonCancellable) { getEvent()?.let { repository.update(it) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/widget/EventWidgetProvider.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.widget import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.event.editor.EventEditorContainer class EventWidgetProvider : AppWidgetProvider() { override fun onReceive(context: Context?, intent: Intent?) { super.onReceive(context, intent) if (intent?.action == WIDGET_ACTION_UPDATE) { val manager = AppWidgetManager.getInstance(context) val component = ComponentName(context!!, EventWidgetProvider::class.java) manager.notifyAppWidgetViewDataChanged( manager.getAppWidgetIds(component), R.id.listView ) } } override fun onUpdate( context: Context?, appWidgetManager: AppWidgetManager?, appWidgetIds: IntArray? ) { super.onUpdate(context, appWidgetManager, appWidgetIds) appWidgetIds?.forEach { onUpdateWidget(context, appWidgetManager, it) } } private fun onUpdateWidget(context: Context?, manager: AppWidgetManager?, id: Int) { val mainIntent = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NAVIGATION_EVENT }, PendingIntent.FLAG_IMMUTABLE ) val itemIntent = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_WIDGET_EVENT }, PendingIntent.FLAG_IMMUTABLE ) val addIntent = PendingIntent.getActivity( context, 0, Intent(context, EventEditorContainer::class.java), PendingIntent.FLAG_IMMUTABLE ) val views = RemoteViews(context?.packageName, R.layout.layout_widget_events) with(views) { setOnClickPendingIntent(R.id.rootView, mainIntent) setOnClickPendingIntent(R.id.actionButton, addIntent) setRemoteAdapter(R.id.listView, Intent(context, EventWidgetService::class.java)) setPendingIntentTemplate(R.id.listView, itemIntent) setEmptyView(R.id.listView, R.id.emptyView) } manager?.notifyAppWidgetViewDataChanged(id, R.id.listView) manager?.updateAppWidget(id, views) } companion object { private const val WIDGET_ACTION_UPDATE = "widget:event:update" fun triggerRefresh(context: Context?) { context?.sendBroadcast(Intent(WIDGET_ACTION_UPDATE).apply { component = ComponentName(context, EventWidgetProvider::class.java) }) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/widget/EventWidgetRemoteViewFactory.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.widget import android.content.Context import android.content.Intent import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.database.AppDatabase import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.event.EventPackage import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking class EventWidgetRemoteViewFactory(private var context: Context) : RemoteViewsService.RemoteViewsFactory { private var itemList = mutableListOf() private fun fetch() { itemList.clear() val events = AppDatabase.getInstance(context).events() var items = emptyList() runBlocking { val job = async { events.fetchPackage() } items = job.await() ?: emptyList() items.forEach { if (it.event.isToday()) itemList.add(it) } } } override fun onDataSetChanged() = fetch() override fun getLoadingView(): RemoteViews = RemoteViews(context.packageName, R.layout.layout_widget_progress) override fun getItemId(position: Int): Long = position.toLong() override fun hasStableIds(): Boolean = true override fun getViewAt(position: Int): RemoteViews { val event = itemList[position].event val subject = itemList[position].subject val itemIntent = Intent().apply { putExtra(MainActivity.EXTRA_EVENT, event) putExtra(MainActivity.EXTRA_SUBJECT, subject) } val views = RemoteViews(context.packageName, R.layout.layout_item_widget) with(views) { setTextViewText(R.id.titleView, event.name) setTextViewText(R.id.summaryView, event.formatSchedule(context)) setOnClickFillInIntent(R.id.rootView, itemIntent) if (subject != null) setInt(R.id.imageView, "setColorFilter", subject.tag.color) else setViewVisibility(R.id.imageView, View.GONE) } return views } override fun getCount(): Int = itemList.size override fun getViewTypeCount(): Int = 1 override fun onCreate() {} override fun onDestroy() {} } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/event/widget/EventWidgetService.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event.widget import android.content.Intent import android.widget.RemoteViewsService class EventWidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent?): RemoteViewsFactory { return EventWidgetRemoteViewFactory(applicationContext) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/log/Log.kt ================================================ package com.isaiahvonrundstedt.fokus.features.log import android.content.Context import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.recyclerview.widget.DiffUtil import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import okio.buffer import okio.sink import java.io.File import java.io.InputStream import java.time.LocalDate import java.time.ZonedDateTime import java.util.* @Parcelize @JsonClass(generateAdapter = true) @Entity(tableName = "logs") data class Log @JvmOverloads constructor( @PrimaryKey var logID: String = UUID.randomUUID().toString(), var title: String? = null, var content: String? = null, var data: String? = null, var type: Int = TYPE_GENERIC, var isImportant: Boolean = false, @TypeConverters(DateTimeConverter::class) var dateTimeTriggered: ZonedDateTime? = null ) : Parcelable, Streamable { fun formatDateTime(context: Context): String? { val currentDateTime = LocalDate.now() // Formats the dateTime object for human reading return if (dateTimeTriggered!!.toLocalDate().isEqual(currentDateTime)) dateTimeTriggered?.format(DateTimeConverter.getDateTimeFormatter(context, true)) else if (dateTimeTriggered!!.toLocalDate().year == currentDateTime.year) dateTimeTriggered?.format(DateTimeConverter.getDateTimeFormatter(context, true)) else dateTimeTriggered?.format( DateTimeConverter.getDateTimeFormatter( context, isShort = true, withYear = true ) ) } @DrawableRes fun getIconResource(): Int { return when (type) { TYPE_TASK -> R.drawable.ic_outline_check_24 TYPE_EVENT -> R.drawable.ic_outline_event_24 TYPE_CLASS -> R.drawable.ic_outline_settings_24 TYPE_GENERIC -> R.drawable.ic_outline_lightbulb_24 else -> R.drawable.ic_outline_lightbulb_24 } } override fun toJsonString(): String? = JsonDataStreamer.encodeToJson(this, Log::class.java) override fun toJsonFile(destination: File, name: String): File { return File(destination, name).apply { this.sink().buffer().use { toJsonString()?.also { json -> it.write(json.toByteArray()) } it.flush() } } } override fun fromInputStream(inputStream: InputStream) { JsonDataStreamer.decodeOnceFromJson(inputStream, Log::class.java)?.also { logID = it.logID title = it.title content = it.content type = it.type isImportant = it.isImportant data = it.data dateTimeTriggered = it.dateTimeTriggered } } companion object { const val TYPE_GENERIC = 0 const val TYPE_TASK = 1 const val TYPE_EVENT = 2 const val TYPE_CLASS = 3 val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Log, newItem: Log): Boolean { return oldItem.logID == newItem.logID } override fun areContentsTheSame(oldItem: Log, newItem: Log): Boolean { return oldItem == newItem } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/log/LogAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.ItemTouchHelper import com.isaiahvonrundstedt.fokus.components.interfaces.Swipeable import com.isaiahvonrundstedt.fokus.databinding.LayoutItemLogBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter class LogAdapter(private var actionListener: ActionListener) : BaseAdapter(Log.DIFF_CALLBACK), Swipeable { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = LayoutItemLogBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding.root) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.onBind(getItem(position)) } override fun onSwipe(position: Int, direction: Int) { if (direction == ItemTouchHelper.START) actionListener.onActionPerformed( getItem(position), ActionListener.Action.DELETE, null ) } class ViewHolder(itemView: View) : BaseViewHolder(itemView) { private val binding = LayoutItemLogBinding.bind(itemView) override fun onBind(data: T) { if (data is Log) { with(data) { binding.titleView.text = title binding.summaryView.text = content binding.dateTimeView.text = data.formatDateTime(binding.root.context) binding.iconView.setImageResource(getIconResource()) } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/log/LogFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.log import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.components.custom.ItemSwipeCallback import com.isaiahvonrundstedt.fokus.databinding.FragmentLogsBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu @AndroidEntryPoint class LogFragment : BaseFragment(), BaseAdapter.ActionListener { private var _binding: FragmentLogsBinding? = null private var controller: NavController? = null private val logAdapter = LogAdapter(this) private val viewModel: LogViewModel by viewModels() private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentLogsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.emptyView)) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_logs) inflateMenu(R.menu.menu_logs) overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) setupNavigation(this, R.drawable.ic_outline_arrow_back_24) { controller?.navigateUp() } } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = logAdapter ItemTouchHelper(ItemSwipeCallback(context, logAdapter)) .attachToRecyclerView(this) } } override fun onStart() { super.onStart() controller = findNavController() viewModel.logs.observe(viewLifecycleOwner) { logAdapter.submitList(it) } viewModel.isEmpty.observe(viewLifecycleOwner) { binding.emptyView.isVisible = it } } override fun onActionPerformed( t: T, action: BaseAdapter.ActionListener.Action, container: View? ) { if (t is Log) { when (action) { BaseAdapter.ActionListener.Action.DELETE -> { viewModel.remove(t) val snackbar = Snackbar.make( binding.recyclerView, R.string.feedback_log_removed, Snackbar.LENGTH_SHORT ) snackbar.setAction(R.string.button_undo) { viewModel.insert(t) } snackbar.show() } BaseAdapter.ActionListener.Action.SELECT -> { } } } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_clear_items -> viewModel.removeLogs() } return true } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/log/LogViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.log import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.repository.LogRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LogViewModel @Inject constructor( private val repository: LogRepository ) : ViewModel() { val logs: LiveData> = repository.fetch() val isEmpty: LiveData = Transformations.map(logs) { it.isNullOrEmpty() } fun insert(log: Log) = viewModelScope.launch { repository.insert(log) } fun remove(log: Log) = viewModelScope.launch { repository.remove(log) } fun removeLogs() = viewModelScope.launch { repository.removeLogs() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/NotificationWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications import android.app.NotificationManager import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.database.repository.LogRepository import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.time.ZonedDateTime // This worker fetches the fokus passed by various // worker classes. It's primary purpose is to only trigger // and to show the fokus. Also to insert the fokus // object to the database. @HiltWorker class NotificationWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val repository: LogRepository, private val notificationManager: NotificationManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val log: Log = convertDataToLog(inputData) log.dateTimeTriggered = ZonedDateTime.now() repository.insert(log) if (log.isImportant) sendNotification(log, notificationManager, log.data) else sendNotification(log, notificationManager) return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/event/EventNotificationScheduler.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.event import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isAfterNow import com.isaiahvonrundstedt.fokus.database.repository.EventRepository import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject // This worker's function is to reschedule all pending workers // that is supposed to trigger at its due minus the interval // This only triggers when the user has changed the fokus interval // for tasks in the Settings @HiltWorker class EventNotificationScheduler @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val repository: EventRepository ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val items = repository.fetchCore() items.forEach { event -> if (event.schedule?.isAfterNow() == true) { val request = OneTimeWorkRequest.Builder(EventNotificationWorker::class.java) .setInputData(convertEventToData(event)) .build() WorkManager.getInstance(applicationContext).enqueueUniqueWork( event.eventID, ExistingWorkPolicy.REPLACE, request ) } } return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/event/EventNotificationWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.event import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isAfterNow import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.notifications.NotificationWorker import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.time.Duration import java.time.ZonedDateTime import java.util.concurrent.TimeUnit // This worker's function is to schedule the fokus worker // for the event schedule minus the interval. @HiltWorker class EventNotificationWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val preferenceManager: PreferenceManager, private val workManager: WorkManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val event = convertDataToEvent(inputData) val notification = Log().apply { title = event.name content = event.formatSchedule(applicationContext) type = Log.TYPE_EVENT data = event.eventID isImportant = event.isImportant } val request = OneTimeWorkRequest.Builder(NotificationWorker::class.java) request.setInputData(convertLogToData(notification)) if (notification.isImportant) { workManager.enqueueUniqueWork( event.eventID, ExistingWorkPolicy.REPLACE, request.build() ) return Result.success() } var executionTime = event.schedule when (preferenceManager.eventReminderInterval) { PreferenceManager.EVENT_REMINDER_INTERVAL_15_MINUTES -> executionTime = event.schedule?.minusMinutes(15) PreferenceManager.EVENT_REMINDER_INTERVAL_30_MINUTES -> executionTime = event.schedule?.minusMinutes(30) PreferenceManager.EVENT_REMINDER_INTERVAL_60_MINUTES -> executionTime = event.schedule?.minusMinutes(60) } if (executionTime?.isAfterNow() == true) request.setInitialDelay( Duration.between(ZonedDateTime.now(), executionTime).toMinutes(), TimeUnit.MINUTES ) workManager.enqueueUniqueWork( event.eventID, ExistingWorkPolicy.REPLACE, request.build() ) return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/subject/ClassNotificationScheduler.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.subject import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.database.repository.SubjectRepository import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject @HiltWorker class ClassNotificationScheduler @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val repository: SubjectRepository, private val workManager: WorkManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val subjectList = repository.fetch() subjectList.forEach { resource -> resource.schedules.forEach { it.subject = resource.subject.code val request = OneTimeWorkRequest.Builder(ClassNotificationWorker::class.java) .setInputData(convertScheduleToData(it)) workManager.enqueueUniqueWork( it.scheduleID, ExistingWorkPolicy.REPLACE, request.build() ) } } return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/subject/ClassNotificationWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.subject import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.* import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isAfterNow import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.notifications.NotificationWorker import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.time.Duration import java.time.ZonedDateTime import java.time.temporal.WeekFields import java.util.* import java.util.concurrent.TimeUnit @HiltWorker class ClassNotificationWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val preferenceManager: PreferenceManager, private val workManager: WorkManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val schedule = convertDataToSchedule(inputData) val log = Log().apply { title = schedule.subject content = schedule.format(applicationContext) type = Log.TYPE_CLASS isImportant = false data = schedule.scheduleID } val request = OneTimeWorkRequest.Builder(NotificationWorker::class.java) request.setInputData(convertLogToData(log)) schedule.parseDaysOfWeek().forEach { var triggerTime = schedule.startTime?.let { time -> Schedule.getNextWeekDay(it, time) } when (preferenceManager.subjectReminderInterval) { PreferenceManager.SUBJECT_REMINDER_INTERVAL_5_MINUTES -> triggerTime = triggerTime?.minusMinutes(5) PreferenceManager.SUBJECT_REMINDER_INTERVAL_15_MINUTES -> triggerTime = triggerTime?.minusMinutes(15) PreferenceManager.SUBJECT_REMINDER_INTERVAL_30_MINUTES -> triggerTime = triggerTime?.minusMinutes(30) } if (triggerTime?.isAfterNow() == true) request.setInitialDelay( Duration.between(ZonedDateTime.now(), triggerTime).toMinutes(), TimeUnit.MINUTES ) val weekFields = WeekFields.of(Locale.getDefault()) val weekNumber = triggerTime?.get(weekFields.weekOfMonth()) if (!schedule.hasWeek(weekNumber!!)) return@forEach workManager.enqueueUniqueWork( schedule.scheduleID, ExistingWorkPolicy.APPEND, request.build() ) reschedule(schedule.scheduleID, inputData, triggerTime) } return Result.success() } private fun reschedule(tag: String, data: Data, triggerTime: ZonedDateTime?) { val request = OneTimeWorkRequest.Builder(ClassNotificationWorker::class.java) .setInputData(data) .setInitialDelay( Duration.between(ZonedDateTime.now(), triggerTime).toMinutes(), TimeUnit.MINUTES ) workManager.enqueueUniqueWork( tag, ExistingWorkPolicy.APPEND, request.build() ) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/task/TaskNotificationScheduler.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.task import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import com.isaiahvonrundstedt.fokus.features.task.Task import dagger.assisted.Assisted import dagger.assisted.AssistedInject // This worker's function is to reschedule all pending workers // that is supposed to trigger at its due minus the interval // This only triggers when the user has changed the fokus interval // for tasks in the Settings @HiltWorker class TaskNotificationScheduler @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val repository: TaskRepository, private val workManager: WorkManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val tasks: List = repository.fetchCore() tasks.forEach { task -> val request = OneTimeWorkRequest.Builder(TaskNotificationWorker::class.java) .setInputData(convertTaskToData(task)) .build() workManager.enqueueUniqueWork(task.taskID, ExistingWorkPolicy.REPLACE, request) } return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/task/TaskNotificationWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.task import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isAfterNow import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.notifications.NotificationWorker import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.time.Duration import java.time.ZonedDateTime import java.util.concurrent.TimeUnit // This worker's function is to schedule the fokus worker // for the task minus the interval. @HiltWorker class TaskNotificationWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val preferenceManager: PreferenceManager, private val workManager: WorkManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val task = convertDataToTask(inputData) val resID = if (task.isDueToday()) R.string.due_today_at else R.string.due_at val log = Log().apply { title = task.name content = if (task.hasDueDate()) String.format( applicationContext.getString(resID), task.dueDate?.format(DateTimeConverter.getDateTimeFormatter(applicationContext)) ) else null type = Log.TYPE_TASK isImportant = task.isImportant data = task.taskID } if (!task.isImportant && !task.hasDueDate()) return Result.success() val request = OneTimeWorkRequest.Builder(NotificationWorker::class.java) .setInputData(convertLogToData(log)) if (log.isImportant) { workManager.enqueueUniqueWork( task.taskID, ExistingWorkPolicy.REPLACE, request.build() ) return Result.success() } var executionTime = task.dueDate when (preferenceManager.taskReminderInterval) { PreferenceManager.TASK_REMINDER_INTERVAL_1_HOUR -> executionTime = task.dueDate?.minusHours(1) PreferenceManager.TASK_REMINDER_INTERVAL_3_HOURS -> executionTime = task.dueDate?.minusHours(3) PreferenceManager.TASK_REMINDER_INTERVAL_24_HOURS -> executionTime = task.dueDate?.minusHours(24) } if (executionTime?.isAfterNow() == true) request.setInitialDelay( Duration.between(ZonedDateTime.now(), executionTime).toMinutes(), TimeUnit.MINUTES ) workManager.enqueueUniqueWork( task.taskID, ExistingWorkPolicy.REPLACE, request.build() ) return Result.success() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/notifications/task/TaskReminderWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.notifications.task import android.app.NotificationManager import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.repository.LogRepository import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.time.* import java.util.concurrent.TimeUnit // This worker's function is to only show reminders // based on the frequency the user has selected; daily or every weekends // This will show a reminders for pending tasks. @HiltWorker class TaskReminderWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParameters: WorkerParameters, private val taskRepository: TaskRepository, private val logRepository: LogRepository, private val preferenceManager: PreferenceManager, private val notificationManager: NotificationManager ) : BaseWorker(context, workerParameters) { override suspend fun doWork(): Result { val currentTime = ZonedDateTime.now() reschedule(applicationContext) val taskSize: Int = taskRepository.fetchCount() var log: Log? = null if (taskSize > 0) { log = Log().apply { title = String.format( applicationContext.getString(R.string.notification_pending_tasks_title), taskSize ) content = applicationContext.getString(R.string.notification_pending_tasks_summary) type = Log.TYPE_TASK dateTimeTriggered = ZonedDateTime.now() } } if (preferenceManager.reminderFrequency == PreferenceManager.DURATION_WEEKENDS && !(currentTime.dayOfWeek == DayOfWeek.SUNDAY || currentTime.dayOfWeek == DayOfWeek.SATURDAY) ) return Result.success() if (log != null) { logRepository.insert(log) sendNotification(log, notificationManager) } return Result.success() } companion object { fun reschedule(context: Context) { val manager = WorkManager.getInstance(context) val preferences = PreferenceManager(context) val reminderTime: ZonedDateTime? = ZonedDateTime.of( LocalDate.now(), preferences.reminderTime, ZoneId.systemDefault() ) val executionTime: ZonedDateTime? = if (ZonedDateTime.now().isBefore(reminderTime)) LocalDate.now().atStartOfDay(ZoneId.systemDefault()) .plusHours((reminderTime?.hour ?: 8).toLong()) .plusMinutes((reminderTime?.minute ?: 30).toLong()) .plusMinutes(1) else LocalDate.now().atStartOfDay(ZoneId.systemDefault()) .plusDays(1) .plusHours((reminderTime?.hour ?: 8).toLong()) .plusMinutes((reminderTime?.minute ?: 30).toLong()) manager.cancelAllWorkByTag(this::class.java.simpleName) val request = OneTimeWorkRequest.Builder(TaskReminderWorker::class.java) .setInitialDelay( Duration.between(ZonedDateTime.now(), executionTime).toMinutes(), TimeUnit.MINUTES ) .addTag(this::class.java.simpleName) .build() manager.enqueue(request) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/schedule/Schedule.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule import android.content.Context import android.os.Parcelable import androidx.annotation.StringRes import androidx.recyclerview.widget.DiffUtil import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import okio.buffer import okio.sink import java.io.File import java.io.InputStream import java.time.* import java.util.* @Parcelize @JsonClass(generateAdapter = true) @Entity( tableName = "schedules", foreignKeys = [ForeignKey( entity = Subject::class, parentColumns = arrayOf("subjectID"), childColumns = arrayOf("subject"), onDelete = ForeignKey.CASCADE )] ) data class Schedule @JvmOverloads constructor( @PrimaryKey var scheduleID: String = UUID.randomUUID().toString(), var classroom: String? = null, var daysOfWeek: Int = 0, var weeksOfMonth: Int = 0, @TypeConverters(DateTimeConverter::class) var startTime: LocalTime? = null, @TypeConverters(DateTimeConverter::class) var endTime: LocalTime? = null, var subject: String? = null ) : Parcelable, Streamable { fun isToday(): Boolean { val currentDate = LocalDate.now() parseDaysOfWeek().forEach { if (it == currentDate.dayOfWeek.value) return@isToday true } return false } fun isTomorrow(): Boolean { val currentDate = LocalDate.now() parseDaysOfWeek().forEach { if (it == currentDate.plusDays(1).dayOfWeek.value) return@isTomorrow true } return false } fun format(context: Context, isAbbreviated: Boolean = false): String { return StringBuilder().apply { append(formatDaysOfWeek(context, isAbbreviated)) append(", ") append(formatBothTime(context)) }.toString() } fun formatBothTime(context: Context): String { return "${formatStartTime(context)} - ${formatEndTime(context)}" } fun formatStartTime(context: Context): String? { return formatTime(context, startTime) } fun formatEndTime(context: Context): String? { return formatTime(context, endTime) } /** * Function to format the daysOfWeek attribute * to human readable form * @param context used to fetch the appropriate string localization * for the string resource id * @return the formatted days of week * (e.g. "Sunday, Monday and Thursday") */ fun formatDaysOfWeek(context: Context, isAbbreviated: Boolean): String { val builder = StringBuilder() val list = parseDaysOfWeek() list.forEachIndexed { index, i -> // Append the appropriate day name string from string resource val resID = if (isAbbreviated) getStringResourceForDayAbbreviated(i) else getStringResourceForDay(i) builder.append(context.getString(resID)) // Check if the item's index is second to last, // if it is, then add an "and" from string resource // and if not, just append a comma if (index == list.size - 2) builder.append(context.getString(R.string.and)) else if (index < list.size - 2) builder.append(", ") } return builder.toString() } @StringRes fun getStringResourceForDayAbbreviated(day: Int): Int { return when (day) { DayOfWeek.SUNDAY.value -> R.string.days_of_week_item_sunday_short DayOfWeek.MONDAY.value -> R.string.days_of_week_item_monday_short DayOfWeek.TUESDAY.value -> R.string.days_of_week_item_tuesday_short DayOfWeek.WEDNESDAY.value -> R.string.days_of_week_item_wednesday_short DayOfWeek.THURSDAY.value -> R.string.days_of_week_item_thursday_short DayOfWeek.FRIDAY.value -> R.string.days_of_week_item_friday_short DayOfWeek.SATURDAY.value -> R.string.days_of_week_item_saturday_short else -> 0 } } @StringRes fun getStringResourceForDay(day: Int): Int { return when (day) { DayOfWeek.SUNDAY.value -> R.string.days_of_week_item_sunday DayOfWeek.MONDAY.value -> R.string.days_of_week_item_monday DayOfWeek.TUESDAY.value -> R.string.days_of_week_item_tuesday DayOfWeek.WEDNESDAY.value -> R.string.days_of_week_item_wednesday DayOfWeek.THURSDAY.value -> R.string.days_of_week_item_thursday DayOfWeek.FRIDAY.value -> R.string.days_of_week_item_friday DayOfWeek.SATURDAY.value -> R.string.days_of_week_item_saturday else -> 0 } } fun parseDaysOfWeek(): List { return Companion.parseDaysOfWeek(daysOfWeek) } fun hasWeek(week: Int): Boolean { return weeksOfMonth and week == week } override fun toJsonString(): String? = JsonDataStreamer.encodeToJson(this, Schedule::class.java) override fun toJsonFile(destination: File, name: String): File { return File(destination, name).apply { this.sink().buffer().use { toJsonString()?.also { json -> it.write(json.toByteArray()) } } } } override fun fromInputStream(inputStream: InputStream) { JsonDataStreamer.decodeOnceFromJson(inputStream, Schedule::class.java)?.also { scheduleID = it.scheduleID daysOfWeek = it.daysOfWeek startTime = it.startTime classroom = it.classroom endTime = it.endTime subject = it.subject } } companion object { const val BIT_VALUE_SUNDAY = 1 const val BIT_VALUE_MONDAY = 2 const val BIT_VALUE_TUESDAY = 4 const val BIT_VALUE_WEDNESDAY = 8 const val BIT_VALUE_THURSDAY = 16 const val BIT_VALUE_FRIDAY = 32 const val BIT_VALUE_SATURDAY = 64 const val BIT_VALUE_WEEK_ONE = 1 const val BIT_VALUE_WEEK_TWO = 2 const val BIT_VALUE_WEEK_THREE = 4 const val BIT_VALUE_WEEK_FOUR = 8 val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Schedule, newItem: Schedule): Boolean { return oldItem.scheduleID == newItem.scheduleID } override fun areContentsTheSame(oldItem: Schedule, newItem: Schedule): Boolean { return oldItem.scheduleID == newItem.scheduleID && oldItem.startTime == newItem.startTime && oldItem.endTime == newItem.endTime && oldItem.daysOfWeek == newItem.daysOfWeek && oldItem.weeksOfMonth == newItem.weeksOfMonth && oldItem.subject == newItem.subject } } fun formatTime(context: Context, time: LocalTime?): String? { return time?.format(DateTimeConverter.getTimeFormatter(context)) } fun parseDaysOfWeek(daysOfWeek: Int): List { val days = mutableListOf() if (daysOfWeek and 1 == BIT_VALUE_SUNDAY) days.add(DayOfWeek.SUNDAY.value) if (daysOfWeek and 2 == BIT_VALUE_MONDAY) days.add(DayOfWeek.MONDAY.value) if (daysOfWeek and 4 == BIT_VALUE_TUESDAY) days.add(DayOfWeek.TUESDAY.value) if (daysOfWeek and 8 == BIT_VALUE_WEDNESDAY) days.add(DayOfWeek.WEDNESDAY.value) if (daysOfWeek and 16 == BIT_VALUE_THURSDAY) days.add(DayOfWeek.THURSDAY.value) if (daysOfWeek and 32 == BIT_VALUE_FRIDAY) days.add(DayOfWeek.FRIDAY.value) if (daysOfWeek and 64 == BIT_VALUE_SATURDAY) days.add(DayOfWeek.SATURDAY.value) return days } fun getNearestDateTime(day: Int, time: LocalTime): ZonedDateTime { val currentDate = LocalDate.now().atStartOfDay(ZoneId.systemDefault()) val currentDayOfWeek = currentDate.dayOfWeek.value var targetDay: Long = day.toLong() if (day < currentDayOfWeek) targetDay += 7 return currentDate.plusDays(targetDay - currentDayOfWeek) .withHour(time.hour) .withMinute(time.minute) .withSecond(time.second) } fun getNextWeekDay(day: Int, time: LocalTime): ZonedDateTime? { var currentDate = LocalDate.now().atStartOfDay(ZoneId.systemDefault()) .plusHours(time.hour.toLong()) .plusMinutes(time.minute.toLong()) if (currentDate.dayOfWeek.value >= day) currentDate = currentDate.plusWeeks(1) return currentDate.with(DayOfWeek.of(day)) } fun toJsonFile( items: List, destination: File, name: String = Streamable.FILE_NAME_SCHEDULE ): File { return File(destination, name).apply { this.sink().buffer().use { JsonDataStreamer.encodeToJson(items, Schedule::class.java)?.also { json -> it.write(json.toByteArray()) } } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/schedule/ScheduleEditor.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule import android.os.Bundle import android.text.format.DateFormat.is24HourFormat import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.AppCompatTextView import androidx.core.os.bundleOf import androidx.core.view.forEach import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.setFragmentResult import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.datetime.timePicker import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.google.android.material.chip.Chip import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.createToast import com.isaiahvonrundstedt.fokus.components.extensions.android.setTextColorFromResource import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toCalendar import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toLocalTime import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toZonedDateTimeToday import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.databinding.LayoutSheetScheduleEditorBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseBottomSheet import java.time.DayOfWeek import java.time.LocalTime import java.util.* class ScheduleEditor(manager: FragmentManager) : BaseBottomSheet(manager) { private var id: String? = null private var classroom: String? = null private var startTime: LocalTime? = null private var endTime: LocalTime? = null private var daysOfWeek: Int = 0 private var weeksOfMonth: Int = 0 private var subjectID: String? = null private var requestKey: String = REQUEST_KEY_INSERT private var _binding: LayoutSheetScheduleEditorBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { _binding = LayoutSheetScheduleEditorBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (!PreferenceManager(requireContext()).allowWeekNumbers) { binding.weekOfMonthGroup.isVisible = false binding.weekNumbersHeader.isVisible = false } arguments?.also { subjectID = it.getString(EXTRA_SUBJECT_ID) it.getParcelable(EXTRA_SCHEDULE)?.also { schedule -> id = schedule.scheduleID classroom = schedule.classroom startTime = schedule.startTime endTime = schedule.endTime daysOfWeek = schedule.daysOfWeek weeksOfMonth = schedule.weeksOfMonth subjectID = schedule.subject requestKey = REQUEST_KEY_UPDATE binding.classroomTextInput.setText(classroom) binding.startTimeTextView.text = Schedule.formatTime(view.context, startTime) binding.endTimeTextView.text = Schedule.formatTime(view.context, endTime) binding.startTimeTextView.setTextColorFromResource(R.color.color_primary_text) binding.endTimeTextView.setTextColorFromResource(R.color.color_primary_text) binding.weekOneChip.isChecked = schedule.hasWeek(Schedule.BIT_VALUE_WEEK_ONE) binding.weekTwoChip.isChecked = schedule.hasWeek(Schedule.BIT_VALUE_WEEK_TWO) binding.weekThreeChip.isChecked = schedule.hasWeek(Schedule.BIT_VALUE_WEEK_THREE) binding.weekFourChip.isChecked = schedule.hasWeek(Schedule.BIT_VALUE_WEEK_FOUR) Schedule.parseDaysOfWeek(daysOfWeek).forEach { day -> when (day) { DayOfWeek.SUNDAY.value -> binding.sundayChip.isChecked = true DayOfWeek.MONDAY.value -> binding.mondayChip.isChecked = true DayOfWeek.TUESDAY.value -> binding.tuesdayChip.isChecked = true DayOfWeek.WEDNESDAY.value -> binding.wednesdayChip.isChecked = true DayOfWeek.THURSDAY.value -> binding.thursdayChip.isChecked = true DayOfWeek.FRIDAY.value -> binding.fridayChip.isChecked = true DayOfWeek.SATURDAY.value -> binding.saturdayChip.isChecked = true } } } } binding.startTimeTextView.setOnClickListener { MaterialDialog(it.context).show { lifecycleOwner(this@ScheduleEditor) title(R.string.dialog_pick_start_time) timePicker( show24HoursView = is24HourFormat(requireContext()), currentTime = startTime?.toZonedDateTimeToday()?.toCalendar() ) { _, time -> startTime = time.toLocalTime() if (endTime == null) endTime = startTime if (startTime!!.isAfter(endTime) || startTime!!.compareTo(endTime) == 0 ) { endTime = startTime ?.plusHours(1) ?.plusMinutes(30) binding.endTimeTextView.text = Schedule.formatTime(it.context, endTime) } } positiveButton(R.string.button_done) { _ -> if (it is AppCompatTextView) { it.text = Schedule.formatTime(it.context, startTime) it.setTextColorFromResource(R.color.color_primary_text) binding.endTimeTextView.setTextColorFromResource(R.color.color_primary_text) } } } } binding.endTimeTextView.setOnClickListener { MaterialDialog(it.context).show { lifecycleOwner(this@ScheduleEditor) title(R.string.dialog_pick_end_time) timePicker( show24HoursView = is24HourFormat(requireContext()), currentTime = endTime?.toZonedDateTimeToday()?.toCalendar() ) { _, time -> endTime = time.toLocalTime() if (startTime == null) startTime = endTime if (endTime!!.isBefore(startTime) || endTime!!.compareTo(startTime) == 0 ) { startTime = endTime ?.minusHours(1) ?.minusMinutes(30) binding.startTimeTextView.text = Schedule.formatTime(it.context, startTime) } } positiveButton(R.string.button_done) { _ -> if (it is AppCompatTextView) { it.text = Schedule.formatTime(it.context, endTime) it.setTextColorFromResource(R.color.color_primary_text) binding.startTimeTextView.setTextColorFromResource(R.color.color_primary_text) } } } } binding.actionButton.setOnClickListener { // reset the variables if (requestKey == REQUEST_KEY_UPDATE) { daysOfWeek = 0 weeksOfMonth = 0 } binding.daysOfWeekGroup.forEach { if ((it as? Chip)?.isChecked == true) { daysOfWeek += when (it.id) { R.id.sundayChip -> Schedule.BIT_VALUE_SUNDAY R.id.mondayChip -> Schedule.BIT_VALUE_MONDAY R.id.tuesdayChip -> Schedule.BIT_VALUE_TUESDAY R.id.wednesdayChip -> Schedule.BIT_VALUE_WEDNESDAY R.id.thursdayChip -> Schedule.BIT_VALUE_THURSDAY R.id.fridayChip -> Schedule.BIT_VALUE_FRIDAY R.id.saturdayChip -> Schedule.BIT_VALUE_SATURDAY else -> 0 } } } binding.weekOfMonthGroup.forEach { if ((it as? Chip)?.isChecked == true) { weeksOfMonth += when (it.id) { R.id.weekOneChip -> Schedule.BIT_VALUE_WEEK_ONE R.id.weekTwoChip -> Schedule.BIT_VALUE_WEEK_TWO R.id.weekThreeChip -> Schedule.BIT_VALUE_WEEK_THREE R.id.weekFourChip -> Schedule.BIT_VALUE_WEEK_FOUR else -> 0 } } } // This conditions is used to check if some fields are // blank or null, if these returned true, // we'll show a Toast then direct the focus to // the corresponding field then return to stop // the execution of the code if (startTime == null) { createToast(R.string.feedback_schedule_empty_start_time) binding.startTimeTextView.performClick() return@setOnClickListener } if (endTime == null) { createToast(R.string.feedback_schedule_empty_end_time) binding.endTimeTextView.performClick() return@setOnClickListener } if (daysOfWeek == 0) { createToast(R.string.feedback_schedule_empty_days) return@setOnClickListener } if (weeksOfMonth == 0) { createToast(R.string.feedback_schedule_empty_days) return@setOnClickListener } val schedule = Schedule( scheduleID = id ?: UUID.randomUUID().toString(), classroom = binding.classroomTextInput.text.toString(), daysOfWeek = daysOfWeek, weeksOfMonth = weeksOfMonth, startTime = startTime, endTime = endTime, subject = subjectID ) setFragmentResult(requestKey, bundleOf(EXTRA_SCHEDULE to schedule)) this.dismiss() } } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val REQUEST_KEY_INSERT = "request:insert" const val REQUEST_KEY_UPDATE = "request:update" const val EXTRA_SCHEDULE = "extra:schedule" const val EXTRA_SUBJECT_ID = "extra:subject:id" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/schedule/picker/SchedulePickerAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule.picker import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.isaiahvonrundstedt.fokus.databinding.LayoutItemScheduleBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import java.time.DayOfWeek class SchedulePickerAdapter(private val actionListener: ActionListener) : BaseAdapter(Schedule.DIFF_CALLBACK) { private val itemList = mutableListOf() fun setItems(items: List) { itemList.clear() items.forEach { it.parseDaysOfWeek().forEach { day -> if (day <= DayOfWeek.SUNDAY.value) { val newSchedule = Schedule( startTime = it.startTime, endTime = it.endTime ) newSchedule.daysOfWeek = day itemList.add(newSchedule) } } } submitList(itemList) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = LayoutItemScheduleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding.root) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.onBind(getItem(position)) } inner class ViewHolder(itemView: View) : BaseViewHolder(itemView) { private val binding = LayoutItemScheduleBinding.bind(itemView) override fun onBind(t: T) { if (t is Schedule) { binding.titleView.text = binding.root.context.getString(t.getStringResourceForDay(t.daysOfWeek)) binding.summaryView.text = t.formatBothTime(binding.root.context) } binding.root.setOnClickListener { actionListener.onActionPerformed(t, ActionListener.Action.SELECT, null) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/schedule/picker/SchedulePickerSheet.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule.picker import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.fragment.app.setFragmentResult import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.databinding.LayoutSheetScheduleBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseBottomSheet class SchedulePickerSheet(private val items: List, manager: FragmentManager) : BaseBottomSheet(manager), BaseAdapter.ActionListener { private var _binding: LayoutSheetScheduleBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { _binding = LayoutSheetScheduleBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(binding.recyclerView) { layoutManager = LinearLayoutManager(context) adapter = SchedulePickerAdapter(this@SchedulePickerSheet).apply { setItems(items) } } } override fun onActionPerformed( t: T, action: BaseAdapter.ActionListener.Action, container: View? ) { if (t is Schedule) { when (action) { BaseAdapter.ActionListener.Action.SELECT -> { setFragmentResult( REQUEST_KEY, bundleOf( EXTRA_SCHEDULE to t ) ) this.dismiss() } BaseAdapter.ActionListener.Action.DELETE -> { } } } } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val REQUEST_KEY = "request:pick" const val EXTRA_SCHEDULE = "extra:schedule" fun show(items: List, manager: FragmentManager) { SchedulePickerSheet(items, manager).show() } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/schedule/viewer/ScheduleViewerAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule.viewer import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.isaiahvonrundstedt.fokus.databinding.LayoutItemScheduleBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter class ScheduleViewerAdapter(items: List) : BaseAdapter(Schedule.DIFF_CALLBACK) { init { submitList(items) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ScheduleViewHolder { val binding = LayoutItemScheduleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ScheduleViewHolder(binding.root) } override fun onBindViewHolder(holder: ScheduleViewHolder, position: Int) { holder.onBind(getItem(position)) } class ScheduleViewHolder(itemView: View) : BaseAdapter.BaseViewHolder(itemView) { private val binding = LayoutItemScheduleBinding.bind(itemView) override fun onBind(t: T) { if (t is Schedule) { binding.titleView.text = t.formatDaysOfWeek(binding.root.context, false) binding.summaryView.text = t.formatBothTime(binding.root.context) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/schedule/viewer/ScheduleViewerSheet.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule.viewer import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.databinding.LayoutSheetScheduleBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseBottomSheet class ScheduleViewerSheet(private val items: List, manager: FragmentManager) : BaseBottomSheet(manager) { private var _binding: LayoutSheetScheduleBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { _binding = LayoutSheetScheduleBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(binding.recyclerView) { layoutManager = LinearLayoutManager(context) adapter = ScheduleViewerAdapter(items) } } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/settings/BackupFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.settings import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.preference.Preference import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.components.extensions.android.startForegroundServiceCompat import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.service.BackupRestoreService import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.databinding.FragmentBackupBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BasePreference import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import dagger.hilt.android.AndroidEntryPoint import java.time.LocalDate import java.time.ZonedDateTime import javax.inject.Inject @AndroidEntryPoint class BackupFragment : BaseFragment() { private var _binding: FragmentBackupBinding? = null private var controller: NavController? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentBackupBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, emptyArray()) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_backup) setupNavigation(this, R.drawable.ic_outline_arrow_back_24) { controller?.navigateUp() } } } override fun onStart() { super.onStart() controller = findNavController() } companion object { @AndroidEntryPoint class BackupPreference : BasePreference() { private lateinit var createLauncher: ActivityResultLauncher private lateinit var restoreLauncher: ActivityResultLauncher @Inject lateinit var manager: PreferenceManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) createLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { val service = Intent(context, BackupRestoreService::class.java).apply { action = BackupRestoreService.ACTION_BACKUP data = it.data?.data } context?.startForegroundServiceCompat(service) } restoreLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { val service = Intent(context, BackupRestoreService::class.java).apply { action = BackupRestoreService.ACTION_RESTORE data = it.data?.data } context?.startForegroundServiceCompat(service) } LocalBroadcastManager.getInstance(requireContext()) .registerReceiver(receiver, IntentFilter(BaseService.ACTION_SERVICE_BROADCAST)) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.xml_settings_backups, rootKey) } private var receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == BaseService.ACTION_SERVICE_BROADCAST) { when (intent.getStringExtra(BaseService.EXTRA_BROADCAST_STATUS)) { BackupRestoreService.BROADCAST_BACKUP_SUCCESS -> setPreferenceSummary( PreferenceManager.PREFERENCE_BACKUP, manager.previousBackupDate.parseForSummary() ) BackupRestoreService.BROADCAST_BACKUP_FAILED -> createSnackbar(R.string.feedback_backup_failed, requireView()) BackupRestoreService.BROADCAST_BACKUP_EMPTY -> createSnackbar(R.string.feedback_backup_empty, requireView()) } } } } override fun onStart() { super.onStart() setPreferenceSummary( PreferenceManager.PREFERENCE_BACKUP, manager.previousBackupDate.parseForSummary() ) findPreference(PreferenceManager.PREFERENCE_BACKUP) ?.setOnPreferenceClickListener { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, BackupRestoreService.FILE_BACKUP_NAME) type = Streamable.MIME_TYPE_ZIP } createLauncher.launch(intent) true } findPreference(PreferenceManager.PREFERENCE_RESTORE) ?.setOnPreferenceClickListener { val intent = Intent.createChooser(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { type = Streamable.MIME_TYPE_ZIP }, getString(R.string.dialog_choose_backup)) restoreLauncher.launch(intent) true } } override fun onDestroy() { LocalBroadcastManager.getInstance(requireContext()) .unregisterReceiver(receiver) super.onDestroy() } private fun ZonedDateTime?.parseForSummary(): String? { if (this == null) return getString(R.string.settings_backup_summary_no_previous) val currentDateTime = ZonedDateTime.now() return if (this.toLocalDate().isEqual(LocalDate.now())) String.format( getString(R.string.today_at), format(DateTimeConverter.getTimeFormatter(requireContext())) ) else if (this.minusDays(1)?.compareTo(currentDateTime) == 0) String.format( getString(R.string.yesterday_at), format(DateTimeConverter.getTimeFormatter(requireContext())) ) else if (this.plusDays(1)?.compareTo(currentDateTime) == 0) String.format( getString(R.string.tomorrow_at), format(DateTimeConverter.getTimeFormatter(requireContext())) ) else format(DateTimeConverter.getDateTimeFormatter(requireContext())) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/settings/SettingsFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.settings import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import android.text.format.DateFormat.is24HourFormat import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatDelegate import androidx.browser.customtabs.CustomTabsIntent import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.navigation.fragment.findNavController import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.datetime.timePicker import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toLocalTime import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.databinding.FragmentSettingsBinding import com.isaiahvonrundstedt.fokus.features.notifications.event.EventNotificationScheduler import com.isaiahvonrundstedt.fokus.features.notifications.subject.ClassNotificationScheduler import com.isaiahvonrundstedt.fokus.features.notifications.task.TaskNotificationScheduler import com.isaiahvonrundstedt.fokus.features.notifications.task.TaskReminderWorker import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BasePreference import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseWorker import java.util.* class SettingsFragment : BaseFragment() { private var _binding: FragmentSettingsBinding? = null private var controller: NavController? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentSettingsBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, emptyArray()) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_settings) setupNavigation(this, R.drawable.ic_outline_arrow_back_24) { controller?.navigateUp() } } } override fun onStart() { super.onStart() controller = findNavController() } companion object { class SettingsFragment : BasePreference() { private var controller: NavController? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.xml_settings_main, rootKey) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) findPreference(PreferenceManager.PREFERENCE_THEME) ?.setOnPreferenceChangeListener { _, value -> if (value is String) { val theme = value.toString() notifyThemeChanged(PreferenceManager.Theme.parse(theme)) } true } findPreference(PreferenceManager.PREFERENCE_TASK_NOTIFICATION) ?.setOnPreferenceChangeListener { _, isChecked -> if (isChecked is Boolean) { val workerClass = TaskNotificationScheduler::class.java if (isChecked) scheduleWorker(workerClass) else cancelWorker(workerClass) } else false } findPreference(PreferenceManager.PREFERENCE_EVENT_NOTIFICATION) ?.setOnPreferenceChangeListener { _, isChecked -> if (isChecked is Boolean) { val workerClass = EventNotificationScheduler::class.java if (isChecked) scheduleWorker(workerClass) else cancelWorker(workerClass) } else false } findPreference(PreferenceManager.PREFERENCE_COURSE_NOTIFICATION) ?.setOnPreferenceChangeListener { _, isChecked -> if (isChecked is Boolean) { val workerClass = ClassNotificationScheduler::class.java if (isChecked) scheduleWorker(workerClass) else cancelWorker(workerClass) } else false } findPreference(PreferenceManager.PREFERENCE_TASK_NOTIFICATION_INTERVAL) ?.setOnPreferenceChangeListener { _, _ -> scheduleWorker(TaskNotificationScheduler::class.java) } findPreference(PreferenceManager.PREFERENCE_EVENT_NOTIFICATION_INTERVAL) ?.setOnPreferenceChangeListener { _, _ -> scheduleWorker(EventNotificationScheduler::class.java) } findPreference(PreferenceManager.PREFERENCE_COURSE_NOTIFICATION_INTERVAL) ?.setOnPreferenceChangeListener { _, _ -> scheduleWorker(ClassNotificationScheduler::class.java) } setPreferenceSummary( PreferenceManager.PREFERENCE_REMINDER_TIME, preferences.reminderTime?.format( DateTimeConverter.getTimeFormatter( requireContext() ) ) ) findPreference(PreferenceManager.PREFERENCE_REMINDER_TIME) ?.setOnPreferenceClickListener { MaterialDialog(requireContext()).show { timePicker( show24HoursView = is24HourFormat(requireContext()) ) { _, time -> preferences.reminderTime = time.toLocalTime() TaskReminderWorker.reschedule(requireContext()) } positiveButton(R.string.button_done) { _ -> it.summary = preferences.reminderTime ?.format(DateTimeConverter.getTimeFormatter(requireContext())) } } true } findPreference(PreferenceManager.PREFERENCE_SYSTEM_NOTIFICATION) ?.setOnPreferenceClickListener { val intent = Intent() with(intent) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { action = Settings.ACTION_APP_NOTIFICATION_SETTINGS putExtra(Settings.EXTRA_APP_PACKAGE, context?.packageName) } else { action = "android.settings.APP_NOTIFICATION_SETTINGS" putExtra("app_package", context?.packageName) putExtra("app_uid", context?.applicationInfo?.uid) } startActivity(this) } true } findPreference(PreferenceManager.PREFERENCE_BACKUP_RESTORE) ?.setOnPreferenceClickListener { controller?.navigate(R.id.navigation_backup) true } findPreference(PreferenceManager.PREFERENCE_BATTERY_OPTIMIZATION) ?.setOnPreferenceClickListener { val manufacturerArray = resources.getStringArray(R.array.oem_battery_optimization) var manufacturer = Build.MANUFACTURER.toLowerCase(Locale.getDefault()) if (!manufacturerArray.contains(manufacturer)) manufacturer = "generic" CustomTabsIntent.Builder().build() .launchUrl( requireContext(), Uri.parse(SETTINGS_URL_BATTERY_OPTIMIZATION + manufacturer) ) true } } private fun notifyThemeChanged(theme: PreferenceManager.Theme) { when (theme) { PreferenceManager.Theme.DARK -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) PreferenceManager.Theme.LIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) PreferenceManager.Theme.SYSTEM -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) else AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY) } } } private fun cancelWorker(worker: Class): Boolean { try { WorkManager.getInstance(requireContext()) .cancelAllWorkByTag(worker.simpleName) return true } catch (e: Exception) { e.printStackTrace() } return false } private fun scheduleWorker(worker: Class): Boolean { try { val request = OneTimeWorkRequest.Builder(worker) .addTag(worker.simpleName) .build() WorkManager.getInstance(requireContext()) .enqueue(request) return true } catch (e: Exception) { e.printStackTrace() } return false } override fun onStart() { super.onStart() controller = Navigation.findNavController(requireActivity(), R.id.navigationHostFragment) } private val preferences by lazy { PreferenceManager(requireContext()) } companion object { const val SETTINGS_URL_BATTERY_OPTIMIZATION = "https://www.dontkillmyapp.com/" } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseActivity.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.os.Build import android.os.Bundle import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.core.view.WindowCompat import com.google.android.material.appbar.MaterialToolbar import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager abstract class BaseActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { window.sharedElementsUseOverlay = false super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) } override fun onResume() { super.onResume() when (PreferenceManager(this).theme) { PreferenceManager.Theme.LIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) PreferenceManager.Theme.DARK -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) PreferenceManager.Theme.SYSTEM -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) else AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY) } } } private var toolbar: MaterialToolbar? = null protected fun setPersistentActionBar(toolbar: MaterialToolbar?) { this.toolbar = toolbar setSupportActionBar(toolbar) this.toolbar?.setNavigationOnClickListener { onBackPressed() } supportActionBar?.setDisplayShowTitleEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(true) } protected fun setToolbarTitle(@StringRes id: Int) { this.toolbar?.title = getString(id) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.view.View import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView abstract class BaseAdapter(callback: DiffUtil.ItemCallback) : ListAdapter(callback) { /** * interface used when triggering * an archive swipe in adapter. */ interface ArchiveListener { /** * @param t the item that will be archived. */ fun onItemArchive(t: T) } /** * interface used when the adapter needs * both SELECT and DELETE actions into one * unified listener */ interface ActionListener { /** * @param t the data that will be passed by the adapter to the view * * @param action the action triggered by the user, e.g. SELECT or DELETE * * @param container the root view of the item view that is needed to perform * transitions */ fun onActionPerformed(t: T, action: Action, container: View?) /** * Actions used by the listener */ enum class Action { SELECT, DELETE } } /** * interface used when the adapter only needs * the SELECT action */ interface SelectListener { /** * @param t the data that will be passed to the view or presenter */ fun onItemSelected(t: T) } abstract class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { abstract fun onBind(data: T) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseBasicAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.view.View import androidx.recyclerview.widget.RecyclerView abstract class BaseBasicAdapter> : RecyclerView.Adapter() { protected val items = arrayListOf() fun submitList(list: List) { items.clear() items.addAll(list) notifyDataSetChanged() } interface ActionListener { fun onActionPerformed(t: T, position: Int, action: Action) enum class Action { SELECT, DELETE } } abstract class BaseBasicViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { abstract fun onBind(t: T, position: Int) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseBottomSheet.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.app.Dialog import android.os.Bundle import android.view.View import android.widget.FrameLayout import androidx.fragment.app.FragmentManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.isaiahvonrundstedt.fokus.R abstract class BaseBottomSheet(private val manager: FragmentManager) : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { dialog?.setOnShowListener { val bottomSheetDialog = dialog as BottomSheetDialog val bottomSheet = bottomSheetDialog.findViewById(R.id.design_bottom_sheet) as FrameLayout with(BottomSheetBehavior.from(bottomSheet)) { state = BottomSheetBehavior.STATE_EXPANDED } } super.onViewCreated(view, savedInstanceState) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = BottomSheetDialog(requireContext(), theme) fun show() { if (!this.isAdded || !this.isVisible) show(manager, this::class.java.name) } inline fun show(sheet: BaseBottomSheet.() -> Unit) { this.sheet() this.show() } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseEditorFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.os.Bundle import android.view.animation.Animation import android.view.animation.AnimationUtils import com.google.android.material.transition.platform.MaterialElevationScale import com.isaiahvonrundstedt.fokus.R abstract class BaseEditorFragment : BaseFragment() { protected val animation: Animation get() = AnimationUtils.loadAnimation(requireContext(), R.anim.anim_fade_in) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) exitTransition = MaterialElevationScale(false).apply { duration = TRANSITION_DURATION } reenterTransition = MaterialElevationScale(true).apply { duration = TRANSITION_DURATION } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.content.Context import android.graphics.Color import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.core.content.ContextCompat import androidx.core.view.* import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentResultListener import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.color.MaterialColors import com.google.android.material.transition.MaterialContainerTransform import com.google.android.material.transition.MaterialElevationScale import com.google.android.material.transition.MaterialFadeThrough import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.getDimension import me.saket.cascade.CascadePopupMenu abstract class BaseFragment : Fragment() { private val parentDrawer: DrawerLayout? get() = getParentView()?.findViewById(R.id.drawerLayout) as? DrawerLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialFadeThrough() returnTransition = MaterialFadeThrough() } /** * @returns the view of the outermost fragment * on the nested fragment setup */ private fun getParentView(): View? { return parentFragment?.parentFragment?.view } /** * @param toolbar adds the navigation icon at the start of * the toolbar and registers an onClick callback on it */ protected fun setupNavigation(toolbar: MaterialToolbar, @DrawableRes iconRes: Int? = null, onNavigate: (() -> Unit)? = null) { with(toolbar) { setNavigationIcon(iconRes ?: R.drawable.ic_outline_menu_24) setNavigationOnClickListener { if (onNavigate != null) { onNavigate() } else { triggerNavigationDrawer() } } } } /** * This function triggers the drawerLayout in the * outermost layer of the nested fragment setup */ private fun triggerNavigationDrawer() { if (parentDrawer?.isDrawerOpen(GravityCompat.START) == true) parentDrawer?.closeDrawer(GravityCompat.START) else parentDrawer?.openDrawer(GravityCompat.START) } /** * This function will set the insets (padding) required * to the individual views so that they won't overlap with * Android's Status and Navigation Bars * @param root the parent view or layout * @param topView the topmost view in the layout such as the AppBar * @param contentViews the views which contains the contents in the layout * such as the RecyclerView and empty views. * @param bottomView views such as the FABs. */ protected fun setInsets( root: View, topView: View, contentViews: Array = emptyArray(), bottomView: View? = null ) { ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> val windowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) topView.updateLayoutParams { topMargin = windowInsets.top } bottomView?.updateLayoutParams { bottomMargin = windowInsets.bottom + view.context.getDimension(R.dimen.container_padding_medium) } contentViews.forEach { contentView -> contentView.updateLayoutParams { bottomMargin = windowInsets.bottom } } WindowInsetsCompat.CONSUMED } } /** * @param view the root view of the fragment */ protected fun hideKeyboardFromCurrentFocus(view: View) { if (view is ViewGroup) findCurrentFocus(view) } /** * @param viewGroup check if any of its children has focus then * hide the keyboard */ private fun findCurrentFocus(viewGroup: ViewGroup) { viewGroup.children.forEach { if (it is ViewGroup) // If the current children is an instance of // a ViewGroup, then iterate its children too. findCurrentFocus(it) else { if (it.hasFocus()) { hideKeyboardFromView(it) return } } } } /** * @param view the view which has the focus * then get the inputMethodManager service then * try to hide the soft keyboard with the view's * window token */ private fun hideKeyboardFromView(view: View) { (requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).run { hideSoftInputFromWindow(view.windowToken, 0) } } protected fun registerForFragmentResult(keys: Array, listener: FragmentResultListener) { keys.forEach { childFragmentManager.setFragmentResultListener(it, viewLifecycleOwner, listener) } } fun buildContainerTransform(@IdRes id: Int = R.id.navigationHostFragment) = MaterialContainerTransform().apply { drawingViewId = id duration = TRANSITION_DURATION scrimColor = Color.TRANSPARENT fadeMode = MaterialContainerTransform.FADE_MODE_OUT interpolator = FastOutSlowInInterpolator() setAllContainerColors( MaterialColors.getColor( requireContext(), R.attr.colorSurface, ContextCompat.getColor(requireContext(), R.color.theme_background) ) ) } fun customPopupProvider(context: Context, anchor: View) = CascadePopupMenu(context, anchor, styler = CascadePopupMenu.Styler( background = { ContextCompat.getDrawable(requireContext(), R.drawable.shape_cascade_background) }, )) companion object { const val TRANSITION_DURATION = 300L const val TRANSITION_ELEMENT_ROOT = "transition:root:" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BasePickerFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.annotation.MenuRes import androidx.annotation.StringRes import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.google.android.material.appbar.MaterialToolbar import com.isaiahvonrundstedt.fokus.R abstract class BasePickerFragment(private val manager: FragmentManager): DialogFragment() { private var toolbar: MaterialToolbar? = null protected fun setupToolbar(toolbar: MaterialToolbar, @StringRes titleRes: Int = 0, @MenuRes menuRes: Int = 0, onNavigate: (() -> Unit)? = null, onMenuItemClicked: ((id: Int) -> Unit)? = null) { this.toolbar = toolbar with(toolbar) { setTitle(titleRes) setNavigationIcon(R.drawable.ic_outline_close_24) setNavigationOnClickListener { if (onNavigate != null) onNavigate() } setOnMenuItemClickListener { if (onMenuItemClicked != null) onMenuItemClicked(it.itemId); true } if (menuRes != 0) inflateMenu(menuRes) } } fun show() { if (!isAdded || !isVisible) { show(manager, this::class.java.simpleName) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, R.style.Fokus_Component_Viewer) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) dialog?.window?.run { setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) setWindowAnimations(R.style.Fokus_Animations_Slide) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BasePreference.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat abstract class BasePreference : PreferenceFragmentCompat() { fun setPreferenceSummary(key: String, summary: String?) { findPreference(key)?.summary = summary } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseService.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.app.Notification import android.app.NotificationManager import android.app.Service import android.content.Context import android.content.Intent import android.os.Build import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.utils.NotificationChannelManager abstract class BaseService : Service() { protected fun startForegroundCompat(id: Int, notification: Notification) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForeground(id, notification) else manager?.notify(id, notification) } protected fun stopForegroundCompat(id: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) stopForeground(true) else manager?.cancel(id) } protected fun createNotification( ongoing: Boolean = false, @StringRes titleRes: Int, @StringRes contentRes: Int = 0, @DrawableRes iconRes: Int = R.drawable.ic_outline_check_24 ): Notification { return NotificationCompat.Builder( this, NotificationChannelManager.CHANNEL_ID_GENERIC ).apply { setSmallIcon(iconRes) setContentTitle(getString(titleRes)) if (contentRes != 0) setContentText(getString(contentRes)) setOngoing(ongoing) setCategory(Notification.CATEGORY_SERVICE) setChannelId(NotificationChannelManager.CHANNEL_ID_GENERIC) if (ongoing) setProgress(0, 0, true) color = ContextCompat.getColor(this@BaseService, R.color.theme_primary) }.build() } protected fun terminateService(status: String? = null, data: String? = null) { if (status != null) sendLocalBroadcast(status, data) stopSelf() } protected fun sendLocalBroadcast(status: String, data: String? = null) { LocalBroadcastManager.getInstance(this) .sendBroadcast(Intent(ACTION_SERVICE_BROADCAST).apply { putExtra(EXTRA_BROADCAST_STATUS, status) if (data != null) putExtra(EXTRA_BROADCAST_DATA, data) }) } protected val manager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager } companion object { const val ACTION_SERVICE_BROADCAST = "action:service:status" const val EXTRA_BROADCAST_STATUS = "extra:broadcast:status" const val EXTRA_BROADCAST_DATA = "extra:broadcast:data" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseViewerFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import com.isaiahvonrundstedt.fokus.R abstract class BaseViewerFragment(private val manager: FragmentManager) : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, R.style.Fokus_Component_Viewer) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) dialog?.window?.run { setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) setWindowAnimations(R.style.Fokus_Animations_Slide) } } fun show() { show(manager, this::class.java.simpleName) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/abstracts/BaseWorker.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.abstracts import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.WorkerParameters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.service.NotificationActionService import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.event.Event import com.isaiahvonrundstedt.fokus.features.log.Log import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.task.Task abstract class BaseWorker(context: Context, workerParameters: WorkerParameters) : CoroutineWorker(context, workerParameters) { companion object { const val NOTIFICATION_ID_EVENT = 14 const val NOTIFICATION_TAG_EVENT = "com:isaiahvonrundstedt:fokus:event" const val NOTIFICATION_CHANNEL_ID_EVENT = "channel:event" const val NOTIFICATION_ID_TASK = 27 const val NOTIFICATION_TAG_TASK = "com:isaiahvonrundstedt:fokus:task" const val NOTIFICATION_CHANNEL_ID_TASK = "channel:task" const val NOTIFICATION_ID_GENERIC = 38 const val NOTIFICATION_TAG_GENERIC = "com:isaiahvonrundstedt:fokus:generic" const val NOTIFICATION_CHANNEL_ID_GENERIC = "channel:generic" private const val EXTRA_LOG_ID = "extra:history:id" private const val EXTRA_LOG_TITLE = "extra:history:title" private const val EXTRA_LOG_CONTENT = "extra:history:content" private const val EXTRA_LOG_TYPE = "extra:history:type" private const val EXTRA_LOG_DATA = "extra:history:data" private const val EXTRA_LOG_PERSISTENCE = "extra:history:persistence" private const val EXTRA_TASK_ID = "extra:task:id" private const val EXTRA_TASK_NAME = "extra:task:name" private const val EXTRA_TASK_NOTES = "extra:task:notes" private const val EXTRA_TASK_SUBJECT = "extra:task:subject" private const val EXTRA_TASK_DUE = "extra:task:due" private const val EXTRA_TASK_IMPORTANCE = "extra:task:importance" private const val EXTRA_EVENT_ID = "extra:event:id" private const val EXTRA_EVENT_NAME = "extra:event:name" private const val EXTRA_EVENT_NOTES = "extra:event:notes" private const val EXTRA_EVENT_LOCATION = "extra:event:location" private const val EXTRA_EVENT_SCHEDULE = "extra:event:schedule" private const val EVENT_EVENT_IMPORTANCE = "extra:event:isImportant" private const val EXTRA_SCHEDULE_ID = "extra:schedule:id" private const val EXTRA_SCHEDULE_DAY_OF_WEEK = "extra:schedule:day" private const val EXTRA_SCHEDULE_START_TIME = "extra:schedule:start:time" private const val EXTRA_SCHEDULE_END_TIME = "extra:schedule:end:time" private const val EXTRA_SCHEDULE_SUBJECT = "extra:schedule:subject" fun convertLogToData(log: Log): Data { return Data.Builder().apply { putString(EXTRA_LOG_ID, log.logID) putString(EXTRA_LOG_TITLE, log.title) putString(EXTRA_LOG_CONTENT, log.content) putString(EXTRA_LOG_DATA, log.data) putInt(EXTRA_LOG_TYPE, log.type) putBoolean(EXTRA_LOG_PERSISTENCE, log.isImportant) }.build() } fun convertTaskToData(task: Task): Data { return Data.Builder().apply { putString(EXTRA_TASK_ID, task.taskID) putString(EXTRA_TASK_NAME, task.name) putString(EXTRA_TASK_NOTES, task.notes) putString(EXTRA_TASK_SUBJECT, task.subject) putString(EXTRA_TASK_DUE, DateTimeConverter.fromZonedDateTime(task.dueDate)) putBoolean(EXTRA_TASK_IMPORTANCE, task.isImportant) }.build() } fun convertEventToData(event: Event): Data { return Data.Builder().apply { putString(EXTRA_EVENT_ID, event.eventID) putString(EXTRA_EVENT_NAME, event.name) putString(EXTRA_EVENT_NOTES, event.notes) putString(EXTRA_EVENT_LOCATION, event.location) putString(EXTRA_EVENT_SCHEDULE, DateTimeConverter.fromZonedDateTime(event.schedule)) putBoolean(EVENT_EVENT_IMPORTANCE, event.isImportant) }.build() } fun convertScheduleToData(schedule: Schedule): Data { return Data.Builder().apply { putString(EXTRA_SCHEDULE_ID, schedule.scheduleID) putString(EXTRA_SCHEDULE_SUBJECT, schedule.subject) putString( EXTRA_SCHEDULE_START_TIME, DateTimeConverter.fromLocalTime(schedule.startTime) ) putString( EXTRA_SCHEDULE_END_TIME, DateTimeConverter.fromLocalTime(schedule.endTime) ) putInt(EXTRA_SCHEDULE_DAY_OF_WEEK, schedule.daysOfWeek) }.build() } fun convertDataToLog(workerData: Data): Log { return Log().apply { workerData.getString(EXTRA_LOG_ID)?.let { logID = it } title = workerData.getString(EXTRA_LOG_TITLE) content = workerData.getString(EXTRA_LOG_CONTENT) data = workerData.getString(EXTRA_LOG_DATA) type = workerData.getInt(EXTRA_LOG_TYPE, Log.TYPE_GENERIC) isImportant = workerData.getBoolean(EXTRA_LOG_PERSISTENCE, false) } } fun convertDataToTask(workerData: Data): Task { return Task().apply { workerData.getString(EXTRA_TASK_ID)?.let { taskID = it } name = workerData.getString(EXTRA_TASK_NAME) notes = workerData.getString(EXTRA_TASK_NOTES) subject = workerData.getString(EXTRA_TASK_SUBJECT) isImportant = workerData.getBoolean(EXTRA_TASK_IMPORTANCE, false) dueDate = DateTimeConverter.toZonedDateTime(workerData.getString(EXTRA_TASK_DUE)) } } fun convertDataToEvent(workerData: Data): Event { return Event().apply { workerData.getString(EXTRA_EVENT_ID)?.let { eventID = it } name = workerData.getString(EXTRA_EVENT_NAME) notes = workerData.getString(EXTRA_EVENT_NOTES) location = workerData.getString(EXTRA_EVENT_LOCATION) schedule = DateTimeConverter.toZonedDateTime(workerData.getString(EXTRA_EVENT_SCHEDULE)) isImportant = workerData.getBoolean(EVENT_EVENT_IMPORTANCE, false) } } fun convertDataToSchedule(workerData: Data): Schedule { return Schedule().apply { workerData.getString(EXTRA_SCHEDULE_ID)?.let { scheduleID = it } subject = workerData.getString(EXTRA_SCHEDULE_SUBJECT) startTime = DateTimeConverter.toLocalTime(workerData.getString(EXTRA_SCHEDULE_START_TIME)) endTime = DateTimeConverter.toLocalTime(workerData.getString(EXTRA_SCHEDULE_END_TIME)) daysOfWeek = workerData.getInt(EXTRA_SCHEDULE_DAY_OF_WEEK, 0) } } } protected fun sendNotification(log: Log, manager: NotificationManager, tag: String? = null) { if (log.type == Log.TYPE_TASK && log.data != null) { val intent = PendingIntent.getService( applicationContext, NotificationActionService.NOTIFICATION_ID_FINISH, Intent(applicationContext, NotificationActionService::class.java).apply { putExtra(NotificationActionService.EXTRA_TASK_ID, log.data) putExtra(NotificationActionService.EXTRA_IS_PERSISTENT, log.isImportant) action = NotificationActionService.ACTION_FINISHED }, PendingIntent.FLAG_IMMUTABLE ) manager.notify( tag ?: NOTIFICATION_TAG_TASK, NOTIFICATION_ID_TASK, createNotification( log, NOTIFICATION_CHANNEL_ID_TASK, NotificationCompat.Action( R.drawable.ic_outline_check_24, applicationContext.getString(R.string.button_mark_as_finished), intent ) ) ) } else if (log.type == Log.TYPE_EVENT) manager.notify( tag ?: NOTIFICATION_TAG_EVENT, NOTIFICATION_ID_EVENT, createNotification(log, NOTIFICATION_CHANNEL_ID_EVENT) ) else manager.notify( tag ?: NOTIFICATION_TAG_GENERIC, NOTIFICATION_ID_GENERIC, createNotification(log, NOTIFICATION_CHANNEL_ID_GENERIC) ) } private fun createNotification( log: Log?, channelID: String, action: NotificationCompat.Action? = null ): Notification { return NotificationCompat.Builder(applicationContext, channelID).apply { setSound(notificationSoundUri) setSmallIcon(log?.getIconResource() ?: R.drawable.ic_outline_check_24) setContentIntent(contentIntent) setContentTitle(log?.title) if (log?.content != null) setContentText(log.content) setOngoing(log?.isImportant == true) if (action != null) addAction(action) color = ContextCompat.getColor(applicationContext, R.color.theme_primary) }.build() } private val contentIntent: PendingIntent get() { return PendingIntent.getActivity( applicationContext, 0, Intent(applicationContext, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) } private val notificationSoundUri: Uri get() = Uri.parse(PreferenceManager.DEFAULT_SOUND) } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/shared/adapters/MenuAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.shared.adapters import android.app.Activity import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.PopupMenu import androidx.annotation.MenuRes import androidx.recyclerview.widget.RecyclerView import com.isaiahvonrundstedt.fokus.databinding.LayoutItemMenuBinding class MenuAdapter( activity: Activity?, @MenuRes private val resId: Int, private val menuItemListener: MenuItemListener ) : RecyclerView.Adapter() { private var itemList = mutableListOf() init { val temp = PopupMenu(activity, null).menu activity?.menuInflater?.inflate(resId, temp) for (i in 0 until temp.size()) { val item = temp.getItem(i) itemList.add(MenuItem(item.itemId, item.icon, item.title.toString())) } notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = LayoutItemMenuBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding.root) } override fun getItemCount(): Int = itemList.size override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.onBind(itemList[position]) } inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val binding = LayoutItemMenuBinding.bind(itemView) fun onBind(item: MenuItem) { binding.iconView.setImageDrawable(item.icon) binding.titleView.text = item.title binding.root.setOnClickListener { menuItemListener.onItemSelected(item.id) } } } data class MenuItem(var id: Int, var icon: Drawable?, var title: String) interface MenuItemListener { fun onItemSelected(id: Int) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/Subject.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject import android.graphics.Color import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Parcelable import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.os.bundleOf import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.database.converter.ColorConverter import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import okio.buffer import okio.sink import java.io.File import java.io.InputStream import java.util.* @Parcelize @JsonClass(generateAdapter = true) @Entity(tableName = "subjects") data class Subject @JvmOverloads constructor( @PrimaryKey @ColumnInfo(index = true) var subjectID: String = UUID.randomUUID().toString(), var code: String? = null, var description: String? = null, var instructor: String? = null, @TypeConverters(ColorConverter::class) var tag: Tag = Tag.SKY, var isSubjectArchived: Boolean = false, ) : Parcelable, Streamable { // Used for the color tag of the subject @JsonClass(generateAdapter = false) enum class Tag(val color: Int) { SKY(Color.parseColor("#2196f3")), GRASS(Color.parseColor("#71a234")), SUNSET(Color.parseColor("#ff7e0f")), LEMON(Color.parseColor("#ffb600")), SEA(Color.parseColor("#01b1af")), GRAPE(Color.parseColor("#9c27b0")), CHERRY(Color.parseColor("#f50057")), CORAL(Color.parseColor("#f15b8d")), MIDNIGHT(Color.parseColor("#1a237e")), LAVENDER(Color.parseColor("#b39ddb")), MINT(Color.parseColor("#009c56")), GRAPHITE(Color.parseColor("#757575")); fun getNameResource(): Int { return when (this) { SKY -> R.string.color_sky GRASS -> R.string.color_grass SUNSET -> R.string.color_sunset LEMON -> R.string.color_lemon SEA -> R.string.color_sea GRAPE -> R.string.color_grape CHERRY -> R.string.color_cherry CORAL -> R.string.color_coral MIDNIGHT -> R.string.color_midnight MINT -> R.string.color_mint LAVENDER -> R.string.color_lavender GRAPHITE -> R.string.color_graphite } } companion object { private val colors: MutableMap = HashMap() init { for (i in values()) colors[i.color] = i } fun convertColorToTag(int: Int): Tag? = colors[int] fun getColors(): IntArray = colors.keys.toIntArray() } } fun tintDrawable(drawable: Drawable?): Drawable? { return drawable?.also { it.mutate() it.colorFilter = BlendModeColorFilterCompat .createBlendModeColorFilterCompat(tag.color, BlendModeCompat.SRC_ATOP) } } override fun toJsonString(): String? = JsonDataStreamer.encodeToJson(this, Subject::class.java) override fun toJsonFile(destination: File, name: String): File { return File(destination, name).apply { this.sink().buffer().use { toJsonString()?.also { json -> it.write(json.toByteArray()) } } } } override fun fromInputStream(inputStream: InputStream) { JsonDataStreamer.decodeOnceFromJson(inputStream, Subject::class.java)?.also { subjectID = it.subjectID code = it.code description = it.description instructor = it.instructor tag = it.tag } } companion object { const val EXTRA_ID = "extra:id" const val EXTRA_CODE = "extra:code" const val EXTRA_DESCRIPTION = "extra:description" const val EXTRA_INSTRUCTOR = "extra:instructor" const val EXTRA_COLOR = "extra:color" const val EXTRA_IS_ARCHIVED = "extra:archived" fun toBundle(subject: Subject): Bundle { return bundleOf( EXTRA_ID to subject.subjectID, EXTRA_CODE to subject.code, EXTRA_DESCRIPTION to subject.description, EXTRA_INSTRUCTOR to subject.instructor, EXTRA_COLOR to ColorConverter.fromColor(subject.tag), EXTRA_IS_ARCHIVED to subject.isSubjectArchived ) } fun fromBundle(bundle: Bundle): Subject? { if (!bundle.containsKey(EXTRA_ID)) return null return Subject( subjectID = bundle.getString(EXTRA_ID)!!, code = bundle.getString(EXTRA_CODE), description = bundle.getString(EXTRA_DESCRIPTION), instructor = bundle.getString(EXTRA_INSTRUCTOR), tag = ColorConverter.toColor(bundle.getInt(EXTRA_COLOR)) ?: Tag.SKY, isSubjectArchived = bundle.getBoolean(EXTRA_IS_ARCHIVED) ) } fun fromInputStream(inputStream: InputStream): Subject { return Subject().apply { this.fromInputStream(inputStream) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/SubjectAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.ItemTouchHelper import com.isaiahvonrundstedt.fokus.components.interfaces.Swipeable import com.isaiahvonrundstedt.fokus.databinding.LayoutItemSubjectBinding import com.isaiahvonrundstedt.fokus.databinding.LayoutItemSubjectSingleBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment class SubjectAdapter( private val actionListener: ActionListener, private val scheduleListener: ScheduleListener, private val archiveListener: ArchiveListener ) : BaseAdapter(SubjectPackage.DIFF_CALLBACK), Swipeable { var constraint = SubjectViewModel.Constraint.TODAY override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { return when (viewType) { ITEM_TYPE_ALL_SCHEDULE -> { val binding = LayoutItemSubjectBinding.inflate( LayoutInflater.from(parent.context), parent, false ) CoreViewHolder(binding.root, actionListener, scheduleListener) } ITEM_TYPE_SINGLE_SCHEDULE_TODAY -> { val binding = LayoutItemSubjectSingleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) TodayViewHolder(binding.root, actionListener) } ITEM_TYPE_SINGLE_SCHEDULE_TOMORROW -> { val binding = LayoutItemSubjectSingleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) TomorrowViewHolder(binding.root, actionListener) } else -> throw IllegalStateException("Unknown Item type") } } override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { holder.onBind(getItem(position)) } override fun getItemViewType(position: Int): Int { return when (constraint) { SubjectViewModel.Constraint.ALL -> ITEM_TYPE_ALL_SCHEDULE SubjectViewModel.Constraint.TODAY -> ITEM_TYPE_SINGLE_SCHEDULE_TODAY SubjectViewModel.Constraint.TOMORROW -> ITEM_TYPE_SINGLE_SCHEDULE_TOMORROW } } override fun onSwipe(position: Int, direction: Int) { if (direction == ItemTouchHelper.START) actionListener.onActionPerformed( getItem(position), ActionListener.Action.DELETE, null ) else if (direction == ItemTouchHelper.END) archiveListener.onItemArchive(getItem(position)) } class CoreViewHolder( itemView: View, private val actionListener: ActionListener, private val scheduleListener: ScheduleListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemSubjectBinding.bind(itemView) override fun onBind(data: T) { if (data is SubjectPackage) { with(data.subject) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + subjectID binding.tagView.setImageDrawable(tintDrawable(binding.tagView.drawable)) binding.nameView.text = code binding.descriptionView.text = description } binding.scheduleView.setOnClickListener { scheduleListener.onScheduleListener(data.schedules) } binding.root.setOnClickListener { actionListener.onActionPerformed(data, ActionListener.Action.SELECT, it) } } } } class TodayViewHolder( itemView: View, private val actionListener: ActionListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemSubjectSingleBinding.bind(itemView) override fun onBind(data: T) { if (data is SubjectPackage) { with(data.subject) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + subjectID binding.tagView.setImageDrawable(tintDrawable(binding.tagView.drawable)) binding.nameView.text = code binding.descriptionView.text = description } val todaySchedule = data.getScheduleToday() binding.scheduleView.text = todaySchedule?.formatBothTime(binding.root.context) binding.root.setOnClickListener { actionListener.onActionPerformed(data, ActionListener.Action.SELECT, it) } } } } class TomorrowViewHolder( itemView: View, private val actionListener: ActionListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemSubjectSingleBinding.bind(itemView) override fun onBind(data: T) { if (data is SubjectPackage) { with(data.subject) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + subjectID binding.tagView.setImageDrawable(tintDrawable(binding.tagView.drawable)) binding.nameView.text = code binding.descriptionView.text = description } val tomorrowSchedule = data.getScheduleTomorrow() binding.scheduleView.text = tomorrowSchedule?.format(itemView.context) binding.root.setOnClickListener { actionListener.onActionPerformed(data, ActionListener.Action.SELECT, it) } } } } interface ScheduleListener { fun onScheduleListener(items: List) } companion object { const val ITEM_TYPE_ALL_SCHEDULE = 0 const val ITEM_TYPE_SINGLE_SCHEDULE_TODAY = 1 const val ITEM_TYPE_SINGLE_SCHEDULE_TOMORROW = 2 } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/SubjectFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.annotation.StringRes import androidx.core.os.bundleOf import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.components.custom.ItemSwipeCallback import com.isaiahvonrundstedt.fokus.components.enums.SortDirection import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.databinding.FragmentSubjectBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.schedule.viewer.ScheduleViewerSheet import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.subject.editor.SubjectEditorFragment import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu @AndroidEntryPoint class SubjectFragment : BaseFragment(), BaseAdapter.ActionListener, SubjectAdapter.ScheduleListener, BaseAdapter.ArchiveListener { private var _binding: FragmentSubjectBinding? = null private var controller: NavController? = null private val binding get() = _binding!! private val subjectAdapter = SubjectAdapter(this, this, this) private val viewModel: SubjectViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentSubjectBinding.inflate(inflater) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.actionButton.transitionName = TRANSITION_ELEMENT_ROOT setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf( binding.recyclerView, binding.emptyViewSubjectsToday, binding.emptyViewSubjectsAll, binding.emptyViewSubjectsTomorrow ), binding.actionButton ) with(binding.appBarLayout.toolbar) { setTitle(getToolbarTitle()) menu?.clear() inflateMenu(R.menu.menu_subjects) overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) setupNavigation(this) menu.findItem(R.id.action_sort_schedule) ?.isVisible = viewModel.constraint != SubjectViewModel.Constraint.ALL } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = subjectAdapter ItemTouchHelper(ItemSwipeCallback(context, subjectAdapter)) .attachToRecyclerView(this) } postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } } override fun onStart() { super.onStart() /** * Get the NavController here so * that it doesn't crash when * the host activity is recreated. */ controller = Navigation.findNavController(requireActivity(), R.id.navigationHostFragment) subjectAdapter.constraint = viewModel.constraint viewModel.subjects.observe(viewLifecycleOwner) { subjectAdapter.submitList(it) } viewModel.isEmpty.observe(viewLifecycleOwner) { when (viewModel.constraint) { SubjectViewModel.Constraint.ALL -> { binding.emptyViewSubjectsAll.isVisible = it binding.emptyViewSubjectsToday.isVisible = false binding.emptyViewSubjectsTomorrow.isVisible = false } SubjectViewModel.Constraint.TODAY -> { binding.emptyViewSubjectsAll.isVisible = false binding.emptyViewSubjectsToday.isVisible = it binding.emptyViewSubjectsTomorrow.isVisible = false } SubjectViewModel.Constraint.TOMORROW -> { binding.emptyViewSubjectsAll.isVisible = false binding.emptyViewSubjectsToday.isVisible = false binding.emptyViewSubjectsTomorrow.isVisible = it } } } } override fun onResume() { super.onResume() binding.actionButton.setOnClickListener { controller?.navigate( R.id.navigation_editor_subject, null, null, FragmentNavigatorExtras(it to TRANSITION_ELEMENT_ROOT) ) } } override fun onActionPerformed( t: T, action: BaseAdapter.ActionListener.Action, container: View? ) { if (t is SubjectPackage) { when (action) { // Create the intent for the editorUI and pass the extras // and wait for the result BaseAdapter.ActionListener.Action.SELECT -> { val transitionName = TRANSITION_ELEMENT_ROOT + t.subject.subjectID val args = bundleOf( SubjectEditorFragment.EXTRA_SUBJECT to Subject.toBundle(t.subject), SubjectEditorFragment.EXTRA_SCHEDULE to t.schedules ) container?.also { controller?.navigate( R.id.navigation_editor_subject, args, null, FragmentNavigatorExtras(it to transitionName) ) } } // Item has been swiped from the RecyclerView, notify user action // in the ViewModel to delete it from the database // then show a SnackBar feedback BaseAdapter.ActionListener.Action.DELETE -> { viewModel.remove(t.subject) createSnackbar(R.string.feedback_subject_removed, binding.recyclerView).run { setAction(R.string.button_undo) { viewModel.insert(t.subject, t.schedules) } } } } } } override fun onItemArchive(t: T) { if (t is SubjectPackage) { t.subject.isSubjectArchived = true viewModel.update(t.subject) } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_code_sort_ascending -> { viewModel.sort = SubjectViewModel.Sort.CODE viewModel.direction = SortDirection.ASCENDING } R.id.action_code_sort_descending -> { viewModel.sort = SubjectViewModel.Sort.CODE viewModel.direction = SortDirection.DESCENDING } R.id.action_description_sort_ascending -> { viewModel.sort = SubjectViewModel.Sort.DESCRIPTION viewModel.direction = SortDirection.ASCENDING } R.id.action_description_sort_descending -> { viewModel.sort = SubjectViewModel.Sort.DESCRIPTION viewModel.direction = SortDirection.DESCENDING } R.id.action_schedule_sort_ascending -> { viewModel.sort = SubjectViewModel.Sort.SCHEDULE viewModel.direction = SortDirection.ASCENDING } R.id.action_schedule_sort_descending -> { viewModel.sort = SubjectViewModel.Sort.SCHEDULE viewModel.direction = SortDirection.DESCENDING } R.id.action_filter_all -> { viewModel.constraint = SubjectViewModel.Constraint.ALL subjectAdapter.constraint = viewModel.constraint with(binding.appBarLayout.toolbar) { setTitle(getToolbarTitle()) menu?.findItem(R.id.action_sort_schedule)?.isVisible = false } } R.id.action_filter_today -> { viewModel.constraint = SubjectViewModel.Constraint.TODAY subjectAdapter.constraint = viewModel.constraint with(binding.appBarLayout.toolbar) { setTitle(getToolbarTitle()) menu?.findItem(R.id.action_sort_schedule)?.isVisible = false } } R.id.action_filter_tomorrow -> { viewModel.constraint = SubjectViewModel.Constraint.TOMORROW subjectAdapter.constraint = viewModel.constraint with(binding.appBarLayout.toolbar) { setTitle(getToolbarTitle()) menu?.findItem(R.id.action_sort_schedule)?.isVisible = true } } R.id.action_archived -> { controller?.navigate(R.id.navigation_archived_subject) } } return true } override fun onScheduleListener(items: List) { ScheduleViewerSheet(items, childFragmentManager) .show() } @StringRes private fun getToolbarTitle(): Int { return when (viewModel.constraint) { SubjectViewModel.Constraint.ALL -> R.string.activity_subjects SubjectViewModel.Constraint.TODAY -> R.string.activity_subjects_today SubjectViewModel.Constraint.TOMORROW -> R.string.activity_subjects_tomorrow } } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/SubjectPackage.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject import android.os.Parcelable import androidx.recyclerview.widget.DiffUtil import androidx.room.Embedded import androidx.room.Relation import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import kotlinx.android.parcel.Parcelize @Parcelize data class SubjectPackage( @Embedded var subject: Subject, @Relation(entity = Schedule::class, parentColumn = "subjectID", entityColumn = "subject") var schedules: List = emptyList() ) : Parcelable { fun hasScheduleToday(): Boolean { for (s: Schedule in schedules) if (s.isToday()) return true return false } fun getScheduleToday(): Schedule? { for (s: Schedule in schedules) if (s.isToday()) return s return null } fun hasScheduleTomorrow(): Boolean { for (s: Schedule in schedules) if (s.isTomorrow()) return true return false } fun getScheduleTomorrow(): Schedule? { for (s: Schedule in schedules) if (s.isTomorrow()) return s return null } companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: SubjectPackage, newItem: SubjectPackage ): Boolean { return oldItem.subject.subjectID == newItem.subject.subjectID } override fun areContentsTheSame( oldItem: SubjectPackage, newItem: SubjectPackage ): Boolean { return oldItem == newItem } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/SubjectViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject import androidx.lifecycle.* import com.isaiahvonrundstedt.fokus.components.enums.SortDirection import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.repository.SubjectRepository import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SubjectViewModel @Inject constructor( private val repository: SubjectRepository, private val preferenceManager: PreferenceManager ) : ViewModel() { private val _subjects: LiveData> = repository.fetchLiveData() val subjects: MediatorLiveData> = MediatorLiveData() val isEmpty: LiveData = Transformations.map(subjects) { it.isNullOrEmpty() } var constraint: Constraint = preferenceManager.subjectConstraint set(value) { field = value preferenceManager.subjectConstraint = value if (constraint == Constraint.ALL) sort = Sort.CODE rearrange(value, sort, direction) } var sort: Sort = preferenceManager.subjectSort set(value) { field = value preferenceManager.subjectSort = value rearrange(constraint, value, direction) } var direction: SortDirection = preferenceManager.subjectSortDirection set(value) { field = value preferenceManager.subjectSortDirection = value rearrange(constraint, sort, value) } init { subjects.addSource(_subjects) { items -> when (constraint) { Constraint.ALL -> subjects.value = items Constraint.TODAY -> subjects.value = items.filter { it.hasScheduleToday() } Constraint.TOMORROW -> subjects.value = items.filter { it.hasScheduleTomorrow() } } } } fun insert(subject: Subject, schedules: List) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.insert(subject, schedules) } fun remove(subject: Subject) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.remove(subject) } fun update(subject: Subject, schedules: List = emptyList()) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.update(subject, schedules) } private fun rearrange(filter: Constraint, sort: Sort, direction: SortDirection) = when (filter) { Constraint.ALL -> { _subjects.value?.let { items -> subjects.value = when (sort) { Sort.CODE -> { when (direction) { SortDirection.ASCENDING -> items.sortedBy { it.subject.code } SortDirection.DESCENDING -> items.sortedByDescending { it.subject.code } } } Sort.DESCRIPTION -> { when (direction) { SortDirection.ASCENDING -> items.sortedBy { it.subject.description } SortDirection.DESCENDING -> items.sortedByDescending { it.subject.description } } } Sort.SCHEDULE -> items.sortedBy { it.subject.code } } } } Constraint.TODAY -> { _subjects.value?.let { items -> subjects.value = when (sort) { Sort.CODE -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.hasScheduleToday() } .sortedBy { it.subject.code } SortDirection.DESCENDING -> items.filter { it.hasScheduleToday() } .sortedByDescending { it.subject.code } } } Sort.DESCRIPTION -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.hasScheduleToday() } .sortedBy { it.subject.description } SortDirection.DESCENDING -> items.filter { it.hasScheduleToday() } .sortedByDescending { it.subject.description } } } Sort.SCHEDULE -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.hasScheduleToday() } .sortedBy { it.getScheduleToday()?.startTime } SortDirection.DESCENDING -> items.filter { it.hasScheduleToday() } .sortedByDescending { it.getScheduleToday()?.startTime } } } } } } Constraint.TOMORROW -> { _subjects.value?.let { items -> subjects.value = when (sort) { Sort.CODE -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.hasScheduleTomorrow() } .sortedBy { it.subject.code } SortDirection.DESCENDING -> items.filter { it.hasScheduleTomorrow() } .sortedByDescending { it.subject.code } } } Sort.DESCRIPTION -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.hasScheduleTomorrow() } .sortedBy { it.subject.description } SortDirection.DESCENDING -> items.filter { it.hasScheduleTomorrow() } .sortedByDescending { it.subject.description } } } Sort.SCHEDULE -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.hasScheduleTomorrow() } .sortedBy { it.getScheduleTomorrow()?.startTime } SortDirection.DESCENDING -> items.filter { it.hasScheduleTomorrow() } .sortedByDescending { it.getScheduleTomorrow()?.startTime } } } } } } } enum class Sort { CODE, DESCRIPTION, SCHEDULE; companion object { fun parse(value: String): Sort { return when (value) { CODE.toString() -> CODE DESCRIPTION.toString() -> DESCRIPTION SCHEDULE.toString() -> SCHEDULE else -> CODE } } } } enum class Constraint { ALL, TODAY, TOMORROW; companion object { fun parse(value: String): Constraint { return when (value) { ALL.toString() -> ALL TODAY.toString() -> TODAY TOMORROW.toString() -> TOMORROW else -> TODAY } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/archived/ArchivedSubjectAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.archived import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.isaiahvonrundstedt.fokus.databinding.LayoutItemArchivedSubjectBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage class ArchivedSubjectAdapter(private val listener: SelectListener) : BaseAdapter(SubjectPackage.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArchivedSubjectViewHolder { val binding = LayoutItemArchivedSubjectBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ArchivedSubjectViewHolder(binding.root, listener) } override fun onBindViewHolder(holder: ArchivedSubjectViewHolder, position: Int) { holder.onBind(getItem(position)) } class ArchivedSubjectViewHolder( itemView: View, private val listener: SelectListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemArchivedSubjectBinding.bind(itemView) override fun onBind(data: T) { if (data is SubjectPackage) { with(data.subject) { binding.root.transitionName = subjectID binding.tagView.setImageDrawable(tintDrawable(binding.tagView.drawable)) binding.titleView.text = code binding.summaryView.text = description } binding.root.setOnClickListener { listener.onItemSelected(data) } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/archived/ArchivedSubjectFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.archived import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.recyclerview.widget.LinearLayoutManager import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.databinding.FragmentArchivedSubjectBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ArchivedSubjectFragment : BaseFragment(), BaseAdapter.SelectListener { private var _binding: FragmentArchivedSubjectBinding? = null private var controller: NavController? = null private val archivedSubjectAdapter = ArchivedSubjectAdapter(this) private val viewModel: ArchivedSubjectViewModel by viewModels() private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentArchivedSubjectBinding.inflate(layoutInflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.recyclerView, binding.emptyView)) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_archives) setNavigationIcon(R.drawable.ic_outline_arrow_back_24) setNavigationOnClickListener { controller?.navigateUp() } } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = archivedSubjectAdapter } } override fun onItemSelected(t: T) { if (t is SubjectPackage) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.dialog_confirm_unarchive_title) message(R.string.dialog_confirm_unarchive_summary) positiveButton { viewModel.removeFromArchive(t) } negativeButton(R.string.button_cancel) } } } override fun onStart() { super.onStart() viewModel.items.observe(viewLifecycleOwner) { archivedSubjectAdapter.submitList(it) } viewModel.isEmpty.observe(viewLifecycleOwner) { binding.emptyView.isVisible = it } } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/archived/ArchivedSubjectViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.archived import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.repository.SubjectRepository import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArchivedSubjectViewModel @Inject constructor( private val subjectRepository: SubjectRepository ) : ViewModel() { val items: LiveData> = subjectRepository.fetchArchivedLiveData() val isEmpty: LiveData = Transformations.map(items) { it.isEmpty() } fun removeFromArchive(subjectPackage: SubjectPackage) = viewModelScope.launch { subjectPackage.subject.isSubjectArchived = false subjectRepository.update(subjectPackage.subject) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/editor/SubjectEditorContainer.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.editor import android.os.Bundle import com.isaiahvonrundstedt.fokus.databinding.ActivityContainerSubjectBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseActivity import dagger.hilt.android.AndroidEntryPoint /** * This activity acts as a container * for the editor fragment. This is * used when needing to show the * editor ui without needing a fragment * transaction. */ @AndroidEntryPoint class SubjectEditorContainer : BaseActivity() { private lateinit var binding: ActivityContainerSubjectBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityContainerSubjectBinding.inflate(layoutInflater) setContentView(binding.root) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/editor/SubjectEditorFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.editor import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.ShareCompat import androidx.core.os.bundleOf import androidx.core.view.children import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.viewModels import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.NavController import androidx.navigation.Navigation import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.color.colorChooser import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.isaiahvonrundstedt.fokus.Fokus import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.components.extensions.android.getCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setTextColorFromResource import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.service.DataExporterService import com.isaiahvonrundstedt.fokus.components.service.DataImporterService import com.isaiahvonrundstedt.fokus.databinding.FragmentEditorSubjectBinding import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.schedule.ScheduleEditor import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseEditorFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu import java.io.File @AndroidEntryPoint class SubjectEditorFragment : BaseEditorFragment(), FragmentResultListener { private var _binding: FragmentEditorSubjectBinding? = null private var controller: NavController? = null private var requestKey = REQUEST_KEY_INSERT private val viewModel: SubjectEditorViewModel by viewModels() private val binding get() = _binding!! private lateinit var importLauncher: ActivityResultLauncher private lateinit var exportLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedElementEnterTransition = buildContainerTransform() sharedElementReturnTransition = buildContainerTransform() importLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { context?.startService(Intent(context, DataImporterService::class.java).apply { this.data = it.data?.data action = DataImporterService.ACTION_IMPORT_SUBJECT }) } } exportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { context?.startService(Intent(context, DataExporterService::class.java).apply { this.data = it.data?.data action = DataExporterService.ACTION_EXPORT_SUBJECT putExtra(DataExporterService.EXTRA_EXPORT_SOURCE, viewModel.getSubject()) putExtra( DataExporterService.EXTRA_EXPORT_DEPENDENTS, viewModel.getSchedules() ) }) } } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentEditorSubjectBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.root.transitionName = TRANSITION_ELEMENT_ROOT setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.contentView), binding.actionButton) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { inflateMenu(R.menu.menu_editor) setNavigationOnClickListener { if (controller?.graph?.id == R.id.navigation_container_subject) requireActivity().finish() else controller?.navigateUp() } overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) } arguments?.getBundle(EXTRA_SUBJECT)?.also { requestKey = REQUEST_KEY_UPDATE Subject.fromBundle(it)?.also { subject -> viewModel.setSubject(subject) binding.root.transitionName = TRANSITION_ELEMENT_ROOT + subject.subjectID } } arguments?.getParcelableArrayList(EXTRA_SCHEDULE)?.also { viewModel.setSchedules(it) } registerForFragmentResult( arrayOf( ScheduleEditor.REQUEST_KEY_INSERT, ScheduleEditor.REQUEST_KEY_UPDATE ), this ) } override fun onStart() { super.onStart() LocalBroadcastManager.getInstance(requireContext()) .registerReceiver(receiver, IntentFilter(BaseService.ACTION_SERVICE_BROADCAST)) if (requestKey == REQUEST_KEY_UPDATE) { binding.codeTextInput.setText(viewModel.getCode()) binding.descriptionTextInput.setText(viewModel.getDescription()) binding.instructorTextInput.setText(viewModel.getInstructor()) } viewModel.subject.observe(this) { if (requestKey == REQUEST_KEY_UPDATE && it != null) { with(it) { binding.codeTextInput.setText(code) binding.descriptionTextInput.setText(description) binding.instructorTextInput.setText(instructor) binding.tagView.setCompoundDrawableAtStart(binding.tagView.getCompoundDrawableAtStart() ?.let { drawable -> tintDrawable(drawable) }) binding.tagView.setText(tag.getNameResource()) } binding.tagView.setTextColorFromResource(R.color.color_primary_text) } } viewModel.schedules.observe(this) { binding.schedulesChipGroup.removeAllViews() it.forEach { schedule -> binding.schedulesChipGroup.addView(Chip(requireContext()).apply { text = schedule.formatDaysOfWeek(requireContext(), true) tag = schedule.scheduleID isCloseIconVisible = true setCloseIconResource(R.drawable.ic_outline_close_24) setOnClickListener { ScheduleEditor(childFragmentManager).show { arguments = bundleOf( ScheduleEditor.EXTRA_SUBJECT_ID to viewModel.getID(), ScheduleEditor.EXTRA_SCHEDULE to schedule ) } } setOnCloseIconClickListener { viewModel.removeSchedule(schedule) createSnackbar(R.string.feedback_schedule_removed, binding.root).run { setAction(R.string.button_undo) { viewModel.addSchedule(schedule) } } } }) } } viewModel.isCodeExists.observe(this) { binding.codeTextInputLayout.error = if (it) getString(R.string.feedback_subject_code_exists) else null } binding.codeTextInput.doAfterTextChanged { viewModel.checkCodeUniqueness(it.toString()) } binding.codeTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setCode(v.text.toString()) } } binding.descriptionTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setDescription(v.text.toString()) } } binding.instructorTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setInstructor(v.text.toString()) } } binding.addActionChip.setOnClickListener { hideKeyboardFromCurrentFocus(requireView()) ScheduleEditor(childFragmentManager).show { arguments = bundleOf( ScheduleEditor.EXTRA_SUBJECT_ID to viewModel.getID() ) } } binding.tagView.setOnClickListener { MaterialDialog(requireContext()).show { lifecycleOwner(this@SubjectEditorFragment) title(R.string.dialog_pick_color_tag) colorChooser(Subject.Tag.getColors(), waitForPositiveButton = false) { _, color -> viewModel.setTag(Subject.Tag.convertColorToTag(color) ?: Subject.Tag.SKY) with(it as TextView) { text = getString(viewModel.getTag()!!.getNameResource()) setTextColorFromResource(R.color.color_primary_text) setCompoundDrawableAtStart( viewModel.getSubject()?.tintDrawable(getCompoundDrawableAtStart()) ) } this.dismiss() } } } binding.actionButton.setOnClickListener { viewModel.setCode(binding.codeTextInput.text.toString()) viewModel.setDescription(binding.descriptionTextInput.text.toString()) viewModel.setInstructor(binding.instructorTextInput.text.toString()) if (viewModel.getCode()?.isEmpty() == true) { createSnackbar(R.string.feedback_subject_empty_name, binding.root) binding.codeTextInput.requestFocus() return@setOnClickListener } if (viewModel.getDescription()?.isEmpty() == true) { createSnackbar(R.string.feedback_subject_empty_description, binding.root) binding.descriptionTextInput.requestFocus() return@setOnClickListener } if (viewModel.getSchedules().size < 1) { createSnackbar(R.string.feedback_subject_no_schedule, binding.root).show() return@setOnClickListener } if (requestKey == REQUEST_KEY_INSERT) viewModel.insert() else viewModel.update() if (controller?.graph?.id == R.id.navigation_container_subject) requireActivity().finish() else controller?.navigateUp() } } override fun onStop() { super.onStop() /** * Check if the soft keyboard is visible * then hide it before the user leaves * the fragment */ hideKeyboardFromCurrentFocus(requireView()) } override fun onFragmentResult(requestKey: String, result: Bundle) { when (requestKey) { ScheduleEditor.REQUEST_KEY_INSERT -> { result.getParcelable(ScheduleEditor.EXTRA_SCHEDULE)?.also { viewModel.addSchedule(it) } } ScheduleEditor.REQUEST_KEY_UPDATE -> { result.getParcelable(ScheduleEditor.EXTRA_SCHEDULE)?.also { viewModel.updateSchedule(it) } } } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_export -> { val fileName = getSharingName() if (fileName == null) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_done) { dismiss() } } return false } /** * Make the user specify where to save * the exported file */ exportLauncher.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, fileName) type = Streamable.MIME_TYPE_ZIP }) } R.id.action_share -> { val fileName = getSharingName() if (fileName == null) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_done) { dismiss() } } return false } /** * We need first to export the data as a raw file * then pass it onto the system sharing component */ context?.startService(Intent(context, DataExporterService::class.java).apply { action = DataExporterService.ACTION_EXPORT_SUBJECT putExtra( DataExporterService.EXTRA_EXPORT_SOURCE, viewModel.getSubject() ) putExtra( DataExporterService.EXTRA_EXPORT_DEPENDENTS, viewModel.getSchedules() ) }) } R.id.action_import -> { val chooser = Intent.createChooser(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { type = Streamable.MIME_TYPE_ZIP }, getString(R.string.dialog_select_file_import)) importLauncher.launch(chooser) } } return true } override fun onDestroy() { super.onDestroy() _binding = null LocalBroadcastManager.getInstance(requireContext()) .unregisterReceiver(receiver) } private var receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == BaseService.ACTION_SERVICE_BROADCAST) { when (intent.getStringExtra(BaseService.EXTRA_BROADCAST_STATUS)) { DataExporterService.BROADCAST_EXPORT_ONGOING -> { createSnackbar( R.string.feedback_export_ongoing, binding.root, Snackbar.LENGTH_INDEFINITE ) } DataExporterService.BROADCAST_EXPORT_COMPLETED -> { createSnackbar(R.string.feedback_export_completed, binding.root) intent.getStringExtra(BaseService.EXTRA_BROADCAST_DATA)?.also { val uri = Fokus.obtainUriForFile(requireContext(), File(it)) startActivity( ShareCompat.IntentBuilder.from(requireActivity()) .addStream(uri) .setType(Streamable.MIME_TYPE_ZIP) .setChooserTitle(R.string.dialog_send_to) .intent ) } } DataExporterService.BROADCAST_EXPORT_FAILED -> { createSnackbar(R.string.feedback_export_failed, binding.root) } DataImporterService.BROADCAST_IMPORT_ONGOING -> { createSnackbar(R.string.feedback_import_ongoing, binding.root) } DataImporterService.BROADCAST_IMPORT_COMPLETED -> { createSnackbar(R.string.feedback_import_completed, binding.root) intent.getParcelableExtra(BaseService.EXTRA_BROADCAST_DATA) ?.also { viewModel.setSubject(it.subject) viewModel.setSchedules(ArrayList(it.schedules)) } } DataImporterService.BROADCAST_IMPORT_FAILED -> { createSnackbar(R.string.feedback_import_failed, binding.root) } } } } } private fun getSharingName(): String? { return when (requestKey) { REQUEST_KEY_INSERT -> { if (binding.codeTextInput.text.isNullOrEmpty() || binding.descriptionTextInput.text.isNullOrEmpty() || binding.schedulesChipGroup.children.none() ) { MaterialDialog(requireContext()).show { title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_dismiss) { dismiss() } } return null } binding.codeTextInput.text.toString() } REQUEST_KEY_UPDATE -> { viewModel.getCode() ?: Streamable.ARCHIVE_NAME_GENERIC } else -> null } } override fun onSaveInstanceState(outState: Bundle) { with(outState) { putParcelable(EXTRA_SUBJECT, viewModel.getSubject()) putParcelableArrayList(EXTRA_SCHEDULE, viewModel.getSchedules()) } super.onSaveInstanceState(outState) } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) savedInstanceState?.run { viewModel.setSubject(getParcelable(EXTRA_SUBJECT)) viewModel.setSchedules(getParcelableArrayList(EXTRA_SCHEDULE) ?: arrayListOf()) } } companion object { const val REQUEST_KEY_INSERT = "request:insert" const val REQUEST_KEY_UPDATE = "request:update" const val EXTRA_SUBJECT = "extra:subject" const val EXTRA_SCHEDULE = "extra:schedule" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/editor/SubjectEditorViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.editor import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.components.extensions.jdk.getIndexByID import com.isaiahvonrundstedt.fokus.database.repository.SubjectRepository import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.Subject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SubjectEditorViewModel @Inject constructor( private val repository: SubjectRepository ) : ViewModel() { private val _subject: MutableLiveData = MutableLiveData(Subject()) private val _schedules: MutableLiveData> = MutableLiveData(arrayListOf()) private val _isCodeExists: MutableLiveData = MutableLiveData(false) val subject: LiveData = _subject val schedules: LiveData> = _schedules val isCodeExists: LiveData = _isCodeExists fun getSubject(): Subject? { return subject.value } fun setSubject(data: Subject?) { _subject.value = data } fun getSchedules(): ArrayList { return schedules.value ?: arrayListOf() } fun setSchedules(items: ArrayList) { _schedules.value = items } fun addSchedule(schedule: Schedule) { val items = ArrayList(getSchedules()) items.add(schedule) setSchedules(items) } fun removeSchedule(schedule: Schedule) { val items = ArrayList(getSchedules()) items.remove(schedule) setSchedules(items) } fun updateSchedule(schedule: Schedule) { val items = ArrayList(getSchedules()) val index: Int = items.getIndexByID(schedule.scheduleID) if (index != -1) { items[index] = schedule setSchedules(items) } } fun checkCodeUniqueness(code: String?) = viewModelScope.launch { val result = repository.checkCodeExists(code, getSubject()?.subjectID) _isCodeExists.value = !result.contains(getID()) && result.isNotEmpty() } fun getID(): String? { return getSubject()?.subjectID } fun getCode(): String? { return getSubject()?.code } fun setCode(code: String) { if (code == getCode()) return val subject = getSubject() subject?.code = code setSubject(subject) } fun getDescription(): String? { return getSubject()?.description } fun setDescription(description: String) { if (description == getDescription()) return val subject = getSubject() subject?.description = description setSubject(subject) } fun getInstructor(): String? { return getSubject()?.instructor } fun setInstructor(instructor: String?) { if (instructor == getInstructor()) return val subject = getSubject() subject?.instructor = instructor android.util.Log.e("DEBUG", instructor ?: "null") setSubject(subject) } fun getTag(): Subject.Tag? { return getSubject()?.tag } fun setTag(tag: Subject.Tag) { val subject = getSubject() subject?.tag = tag setSubject(subject) } fun insert() = viewModelScope.launch(Dispatchers.IO + NonCancellable) { getSubject()?.let { repository.insert(it, getSchedules()) } } fun update() = viewModelScope.launch(Dispatchers.IO + NonCancellable) { getSubject()?.let { repository.update(it, getSchedules()) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/picker/SubjectPickerAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.picker import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.isaiahvonrundstedt.fokus.components.interfaces.Swipeable import com.isaiahvonrundstedt.fokus.databinding.LayoutItemSubjectPickerBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage class SubjectPickerAdapter(private val actionListener: ActionListener) : BaseAdapter(SubjectPackage.DIFF_CALLBACK), Swipeable { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = LayoutItemSubjectPickerBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolder(binding.root) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.onBind(getItem(position)) } override fun onSwipe(position: Int, direction: Int) { actionListener.onActionPerformed(getItem(position), ActionListener.Action.DELETE, null) } inner class ViewHolder(itemView: View) : BaseViewHolder(itemView) { private val binding = LayoutItemSubjectPickerBinding.bind(itemView) override fun onBind(t: T) { if (t is SubjectPackage) { with(t.subject) { binding.root.transitionName = subjectID binding.tagView.setImageDrawable(tintDrawable(binding.tagView.drawable)) binding.titleView.text = code binding.summaryView.text = description } binding.root.setOnClickListener { actionListener.onActionPerformed(t, ActionListener.Action.SELECT, null) } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/picker/SubjectPickerFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.picker import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.components.custom.ItemSwipeCallback import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.databinding.FragmentPickerSubjectBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BasePickerFragment import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class SubjectPickerFragment(fragmentManager: FragmentManager): BasePickerFragment(fragmentManager), BaseAdapter.ActionListener { private var _binding: FragmentPickerSubjectBinding? = null private val binding get() = _binding!! private val pickerAdapter = SubjectPickerAdapter(this) private val viewModel: SubjectPickerViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentPickerSubjectBinding.inflate(inflater, container, false) return binding.root } override fun onDestroy() { _binding = null super.onDestroy() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupToolbar(binding.appBarLayout.toolbar, R.string.dialog_assign_subject, onNavigate = { dismiss() }) with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = pickerAdapter ItemTouchHelper(ItemSwipeCallback(context, pickerAdapter)) .attachToRecyclerView(binding.recyclerView) } } override fun onStart() { super.onStart() viewModel.subjects.observe(this) { pickerAdapter.submitList(it) } viewModel.isEmpty.observe(this) { binding.emptyView.isVisible = it } } override fun onActionPerformed( t: T, action: BaseAdapter.ActionListener.Action, container: View? ) { if (t is SubjectPackage) { when (action) { BaseAdapter.ActionListener.Action.SELECT -> { setFragmentResult(REQUEST_KEY_PICK, bundleOf(EXTRA_SELECTED_SUBJECT to t)) dismiss() } BaseAdapter.ActionListener.Action.DELETE -> { viewModel.remove(t.subject) createSnackbar(R.string.feedback_subject_removed).run { setAction(R.string.button_undo) { viewModel.insert(t.subject, t.schedules) } } } } } } companion object { const val REQUEST_KEY_PICK = "request:pick:subject" const val EXTRA_SELECTED_SUBJECT = "extra:subject" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/picker/SubjectPickerViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.picker import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.repository.SubjectRepository import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SubjectPickerViewModel @Inject constructor( private val repository: SubjectRepository, ) : ViewModel() { val subjects: LiveData> = repository.fetchLiveData() val isEmpty: LiveData = Transformations.map(subjects) { it.isEmpty() } fun insert(subject: Subject, scheduleList: List) = viewModelScope.launch { repository.insert(subject, scheduleList) } fun remove(subject: Subject) = viewModelScope.launch { repository.remove(subject) } fun update(subject: Subject, scheduleList: List) = viewModelScope.launch { repository.update(subject, scheduleList) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/widget/SubjectWidgetProvider.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.widget import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.subject.editor.SubjectEditorContainer class SubjectWidgetProvider : AppWidgetProvider() { override fun onReceive(context: Context?, intent: Intent?) { super.onReceive(context, intent) if (intent?.action == WIDGET_ACTION_UPDATE) { val manager = AppWidgetManager.getInstance(context) val component = ComponentName(context!!, SubjectWidgetProvider::class.java) manager.notifyAppWidgetViewDataChanged( manager.getAppWidgetIds(component), R.id.listView ) } } override fun onUpdate( context: Context?, appWidgetManager: AppWidgetManager?, appWidgetIds: IntArray? ) { super.onUpdate(context, appWidgetManager, appWidgetIds) appWidgetIds?.forEach { onUpdateWidget(context, appWidgetManager, it) } } private fun onUpdateWidget(context: Context?, manager: AppWidgetManager?, id: Int) { val mainIntent = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NAVIGATION_SUBJECT }, PendingIntent.FLAG_IMMUTABLE ) val itemIntent = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_WIDGET_SUBJECT }, PendingIntent.FLAG_IMMUTABLE ) val addIntent = PendingIntent.getActivity( context, 0, Intent(context, SubjectEditorContainer::class.java), PendingIntent.FLAG_IMMUTABLE ) val views = RemoteViews(context?.packageName, R.layout.layout_widget_subjects) with(views) { setOnClickPendingIntent(R.id.rootView, mainIntent) setOnClickPendingIntent(R.id.actionButton, addIntent) setRemoteAdapter(R.id.listView, Intent(context, SubjectWidgetService::class.java)) setPendingIntentTemplate(R.id.listView, itemIntent) setEmptyView(R.id.listView, R.id.emptyView) } manager?.notifyAppWidgetViewDataChanged(id, R.id.listView) manager?.updateAppWidget(id, views) } companion object { private const val WIDGET_ACTION_UPDATE = "widget:event:update" fun triggerRefresh(context: Context?) { context?.sendBroadcast(Intent(WIDGET_ACTION_UPDATE).apply { component = ComponentName(context, SubjectWidgetProvider::class.java) }) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/widget/SubjectWidgetRemoteViewFactory.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.widget import android.content.Context import android.content.Intent import android.widget.RemoteViews import android.widget.RemoteViewsService import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.database.AppDatabase import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking class SubjectWidgetRemoteViewFactory(private var context: Context) : RemoteViewsService.RemoteViewsFactory { private var itemList = mutableListOf() private fun fetch() { itemList.clear() val subjects = AppDatabase.getInstance(context)?.subjects() var items = emptyList() runBlocking { val job = async { subjects?.fetchAsPackage() } items = job.await() ?: emptyList() items.forEach { resource -> resource.schedules.forEach { if (it.isToday()) itemList.add(resource) } } } } override fun onDataSetChanged() = fetch() override fun getLoadingView(): RemoteViews = RemoteViews(context.packageName, R.layout.layout_widget_progress) override fun getItemId(position: Int): Long = position.toLong() override fun hasStableIds(): Boolean = true override fun getViewAt(position: Int): RemoteViews { val subject = itemList[position].subject val schedules = itemList[position].schedules val schedule: Schedule? = schedules.run { forEach { if (it.isToday()) return@run it } return@run null } val itemIntent = Intent().apply { putExtra(MainActivity.EXTRA_SUBJECT, subject) putExtra(MainActivity.EXTRA_SCHEDULES, schedule) } val views = RemoteViews(context.packageName, R.layout.layout_item_widget) with(views) { setTextViewText(R.id.titleView, subject.code) setTextViewText(R.id.summaryView, schedule?.formatBothTime(context)) setOnClickFillInIntent(R.id.listView, itemIntent) setInt(R.id.imageView, "setColorFilter", subject.tag.color) } return views } override fun getCount(): Int = itemList.size override fun getViewTypeCount(): Int = 1 override fun onCreate() {} override fun onDestroy() {} } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/subject/widget/SubjectWidgetService.kt ================================================ package com.isaiahvonrundstedt.fokus.features.subject.widget import android.content.Intent import android.widget.RemoteViewsService class SubjectWidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent?): RemoteViewsFactory { return SubjectWidgetRemoteViewFactory(applicationContext) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/Task.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task import android.content.Context import android.os.Bundle import android.os.Parcelable import androidx.core.os.bundleOf import androidx.room.* import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isAfterNow import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isToday import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isTomorrow import com.isaiahvonrundstedt.fokus.components.extensions.jdk.isYesterday import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.json.JsonDataStreamer import com.isaiahvonrundstedt.fokus.database.converter.DateTimeConverter import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize import okio.buffer import okio.sink import java.io.File import java.io.InputStream import java.time.ZonedDateTime import java.util.* @Parcelize @JsonClass(generateAdapter = true) @Entity( tableName = "tasks", foreignKeys = [ ForeignKey( entity = Subject::class, parentColumns = arrayOf("subjectID"), childColumns = arrayOf("subject"), onDelete = ForeignKey.SET_NULL )] ) data class Task @JvmOverloads constructor( @PrimaryKey @ColumnInfo(index = true) var taskID: String = UUID.randomUUID().toString(), var name: String? = null, var notes: String? = null, var subject: String? = null, var isImportant: Boolean = false, @TypeConverters(DateTimeConverter::class) var dueDate: ZonedDateTime? = null, var isFinished: Boolean = false, var isTaskArchived: Boolean = false, @TypeConverters(DateTimeConverter::class) var dateAdded: ZonedDateTime? = ZonedDateTime.now(), ) : Parcelable, Streamable { fun hasDueDate(): Boolean { return dueDate != null } fun isDueDateInFuture(): Boolean { return dueDate?.isAfterNow() == true } fun isDueToday(): Boolean { return dueDate?.isToday() == true } fun formatDueDate(context: Context): String? { if (dueDate == null) return "" // Check if the day on the task's due is today return if (dueDate?.isToday() == true) String.format( context.getString(R.string.today_at), dueDate?.format(DateTimeConverter.getTimeFormatter(context)) ) // Now check if the day is yesterday else if (dueDate?.isYesterday() == true) String.format( context.getString(R.string.yesterday_at), dueDate?.format(DateTimeConverter.getTimeFormatter(context)) ) // Now check if its tomorrow else if (dueDate?.isTomorrow() == true) String.format( context.getString(R.string.tomorrow_at), dueDate?.format(DateTimeConverter.getTimeFormatter(context)) ) // Just print the date what could go wrong? else dueDate?.format(DateTimeConverter.getDateTimeFormatter(context)) } override fun toJsonString(): String? = JsonDataStreamer.encodeToJson(this, Task::class.java) override fun toJsonFile(destination: File, name: String): File { return File(destination, name).apply { this.sink().buffer().use { toJsonString()?.also { json -> it.write(json.toByteArray()) } } } } override fun fromInputStream(inputStream: InputStream) { JsonDataStreamer.decodeOnceFromJson(inputStream, Task::class.java)?.also { taskID = it.taskID name = it.name dueDate = it.dueDate notes = it.notes subject = it.subject dateAdded = it.dateAdded isImportant = it.isImportant isFinished = it.isFinished } } companion object { const val EXTRA_ID = "extra:id" const val EXTRA_NAME = "extra:name" const val EXTRA_DUE_DATE = "extra:due" const val EXTRA_NOTES = "extra:notes" const val EXTRA_SUBJECT = "extra:subject" const val EXTRA_DATE_ADDED = "extra:added" const val EXTRA_IS_IMPORTANT = "extra:important" const val EXTRA_IS_FINISHED = "extra:finished" const val EXTRA_IS_ARCHIVED = "extra:archived" fun toBundle(task: Task): Bundle { return bundleOf( EXTRA_ID to task.taskID, EXTRA_NAME to task.name, EXTRA_DUE_DATE to task.dueDate, EXTRA_NOTES to task.notes, EXTRA_SUBJECT to task.subject, EXTRA_DATE_ADDED to task.dateAdded, EXTRA_IS_FINISHED to task.isFinished, EXTRA_IS_IMPORTANT to task.isImportant, EXTRA_IS_ARCHIVED to task.isTaskArchived ) } fun fromBundle(bundle: Bundle): Task? { if (!bundle.containsKey(EXTRA_ID)) return null return Task( taskID = bundle.getString(EXTRA_ID)!!, name = bundle.getString(EXTRA_NAME), dueDate = bundle.getSerializable(EXTRA_DUE_DATE) as? ZonedDateTime, notes = bundle.getString(EXTRA_NOTES), subject = bundle.getString(EXTRA_SUBJECT), dateAdded = bundle.getSerializable(EXTRA_DATE_ADDED) as ZonedDateTime, isFinished = bundle.getBoolean(EXTRA_IS_FINISHED), isImportant = bundle.getBoolean(EXTRA_IS_IMPORTANT), isTaskArchived = bundle.getBoolean(EXTRA_IS_ARCHIVED) ) } fun fromInputStream(inputStream: InputStream): Task { return Task().apply { this.fromInputStream(inputStream) } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/TaskAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.AppCompatCheckBox import androidx.core.view.isVisible import androidx.recyclerview.widget.ItemTouchHelper import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.getCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setStrikeThroughEffect import com.isaiahvonrundstedt.fokus.components.extensions.android.setTextColorFromResource import com.isaiahvonrundstedt.fokus.components.interfaces.Swipeable import com.isaiahvonrundstedt.fokus.databinding.LayoutItemTaskBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment class TaskAdapter( private val actionListener: ActionListener, private val statusListener: TaskStatusListener, private val archiveListener: ArchiveListener ) : BaseAdapter(TaskPackage.DIFF_CALLBACK), Swipeable { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder { val binding = LayoutItemTaskBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return TaskViewHolder(binding.root, actionListener, statusListener) } override fun onBindViewHolder(holder: TaskViewHolder, position: Int) { holder.onBind(getItem(position)) } override fun onSwipe(position: Int, direction: Int) { when (direction) { ItemTouchHelper.START -> { actionListener.onActionPerformed( getItem(position), ActionListener.Action.DELETE, null ) } ItemTouchHelper.END -> { archiveListener.onItemArchive(getItem(position)) } } } class TaskViewHolder( itemView: View, private val actionListener: ActionListener, private val statusListener: TaskStatusListener ) : BaseViewHolder(itemView) { private val binding = LayoutItemTaskBinding.bind(itemView) override fun onBind(data: T) { if (data is TaskPackage) { with(data.task) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + taskID val textColorRes = if (isFinished) R.color.color_secondary_text else R.color.color_primary_text binding.checkBox.isChecked = isFinished binding.taskNameView.text = name binding.taskNameView.setTextColorFromResource(textColorRes) binding.taskNameView.setStrikeThroughEffect(isFinished) if (hasDueDate()) binding.dueDateView.text = formatDueDate(binding.root.context) else binding.dueDateView.isVisible = false } if (data.subject != null) { with(binding.subjectView) { text = data.subject?.code setCompoundDrawableAtStart( data.subject?.tintDrawable( getCompoundDrawableAtStart() ) ) } } else binding.subjectView.isVisible = false binding.checkBox.setOnClickListener { view -> with(view as AppCompatCheckBox) { data.task.isFinished = isChecked binding.taskNameView.setStrikeThroughEffect(isChecked) if (isChecked) binding.taskNameView.setTextColorFromResource(R.color.color_secondary_text) } statusListener.onStatusChanged(data, view.isChecked) } binding.root.setOnClickListener { actionListener.onActionPerformed(data, ActionListener.Action.SELECT, it) } } } } interface TaskStatusListener { fun onStatusChanged(taskPackage: TaskPackage, isFinished: Boolean) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/TaskFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task import android.graphics.Color import android.media.RingtoneManager import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.annotation.StringRes import androidx.core.os.bundleOf import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.components.custom.ItemSwipeCallback import com.isaiahvonrundstedt.fokus.components.enums.SortDirection import com.isaiahvonrundstedt.fokus.components.extensions.android.createSnackbar import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.databinding.FragmentTaskBinding import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.task.editor.TaskEditorFragment import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu import nl.dionsegijn.konfetti.core.Party import nl.dionsegijn.konfetti.core.Position import nl.dionsegijn.konfetti.core.emitter.Emitter import nl.dionsegijn.konfetti.core.models.Shape import nl.dionsegijn.konfetti.core.models.Size import java.io.File import java.util.concurrent.TimeUnit @AndroidEntryPoint class TaskFragment : BaseFragment(), BaseAdapter.ActionListener, TaskAdapter.TaskStatusListener, BaseAdapter.ArchiveListener { private var _binding: FragmentTaskBinding? = null private var controller: NavController? = null private val binding get() = _binding!! private val taskAdapter = TaskAdapter(this, this, this) private val viewModel: TaskViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentTaskBinding.inflate(inflater) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.actionButton.transitionName = TRANSITION_ELEMENT_ROOT setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf( binding.recyclerView, binding.emptyViewPendingTasks, binding.emptyViewFinishedTasks), binding.actionButton ) with(binding.appBarLayout.toolbar) { setTitle(getToolbarTitle()) inflateMenu(R.menu.menu_tasks) overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) setupNavigation(this) } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = taskAdapter ItemTouchHelper(ItemSwipeCallback(context, taskAdapter)) .attachToRecyclerView(this) } postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } ItemTouchHelper(ItemSwipeCallback(requireContext(), taskAdapter)) .attachToRecyclerView(binding.recyclerView) } override fun onStart() { super.onStart() /** * Get the NavController here so * that it doesn't crash when * the host activity is recreated. */ controller = Navigation.findNavController(requireActivity(), R.id.navigationHostFragment) viewModel.tasks.observe(viewLifecycleOwner) { taskAdapter.submitList(it) } viewModel.isEmpty.observe(viewLifecycleOwner) { when (viewModel.filterOption) { TaskViewModel.Constraint.ALL -> { binding.emptyViewPendingTasks.isVisible = it binding.emptyViewFinishedTasks.isVisible = false } TaskViewModel.Constraint.PENDING -> { binding.emptyViewPendingTasks.isVisible = it binding.emptyViewFinishedTasks.isVisible = false } TaskViewModel.Constraint.FINISHED -> { binding.emptyViewPendingTasks.isVisible = false binding.emptyViewFinishedTasks.isVisible = it } } } } override fun onResume() { super.onResume() binding.actionButton.setOnClickListener { controller?.navigate( R.id.navigation_editor_task, null, null, FragmentNavigatorExtras(it to TRANSITION_ELEMENT_ROOT) ) } } // Update the task in the database then show // snackbar feedback and also if the sounds if turned on // play a fokus sound. override fun onStatusChanged(taskPackage: TaskPackage, isFinished: Boolean) { viewModel.update(taskPackage.task) if (isFinished) { createSnackbar(R.string.feedback_task_marked_as_finished, binding.recyclerView) with(PreferenceManager(requireContext())) { if (confetti) { binding.confettiView.start(Party( colors = listOf(Color.YELLOW, Color.MAGENTA, Color.CYAN), shapes = listOf(Shape.Square, Shape.Circle), speed = 1f, maxSpeed = 5f, damping = 0.9f, fadeOutEnabled = true, timeToLive = 1000L, spread = 360, position = Position.Relative(0.5, 0.3), size = listOf(Size(12, 5f)), emitter = Emitter(duration = 100, TimeUnit.MILLISECONDS).max(100), )) } if (sounds) RingtoneManager.getRingtone( requireContext(), Uri.parse(PreferenceManager.DEFAULT_SOUND) ).play() } } } // Callback from the RecyclerView Adapter override fun onActionPerformed( t: T, action: BaseAdapter.ActionListener.Action, container: View? ) { if (t is TaskPackage) { when (action) { // Create the intent to the editorUI and pass the extras // and wait for the result. BaseAdapter.ActionListener.Action.SELECT -> { val transitionName = TRANSITION_ELEMENT_ROOT + t.task.taskID val args = bundleOf( TaskEditorFragment.EXTRA_TASK to Task.toBundle(t.task), TaskEditorFragment.EXTRA_ATTACHMENTS to t.attachments, TaskEditorFragment.EXTRA_SUBJECT to t.subject?.let { Subject.toBundle(it) } ) container?.also { controller?.navigate( R.id.navigation_editor_task, args, null, FragmentNavigatorExtras(it to transitionName) ) } } // The item has been swiped down from the recyclerView // remove the item from the database and show a snackbar // feedback BaseAdapter.ActionListener.Action.DELETE -> { viewModel.remove(t.task) createSnackbar(R.string.feedback_task_removed, binding.recyclerView).run { addCallback(object : Snackbar.Callback() { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { super.onDismissed(transientBottomBar, event) if (event != DISMISS_EVENT_ACTION) t.attachments.forEach { attachment -> if (attachment.type == Attachment.TYPE_IMPORTED_FILE) attachment.target?.also { File(it).delete() } } } }) setAction(R.string.button_undo) { viewModel.insert(t.task, t.attachments) } } } } } } override fun onItemArchive(t: T) { if (t is TaskPackage) { t.task.isTaskArchived = true viewModel.update(t.task) } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_name_sort_ascending -> { viewModel.sort = TaskViewModel.Sort.NAME viewModel.sortDirection = SortDirection.ASCENDING } R.id.action_name_sort_descending -> { viewModel.sort = TaskViewModel.Sort.NAME viewModel.sortDirection = SortDirection.DESCENDING } R.id.action_due_sort_ascending -> { viewModel.sort = TaskViewModel.Sort.DUE viewModel.sortDirection = SortDirection.ASCENDING } R.id.action_due_sort_descending -> { viewModel.sort = TaskViewModel.Sort.DUE viewModel.sortDirection = SortDirection.DESCENDING } R.id.action_filter_all -> { viewModel.filterOption = TaskViewModel.Constraint.ALL binding.appBarLayout.toolbar.setTitle(getToolbarTitle()) } R.id.action_filter_pending -> { viewModel.filterOption = TaskViewModel.Constraint.PENDING binding.appBarLayout.toolbar.setTitle(getToolbarTitle()) } R.id.action_filter_finished -> { viewModel.filterOption = TaskViewModel.Constraint.FINISHED binding.appBarLayout.toolbar.setTitle(getToolbarTitle()) } R.id.action_archived -> { controller?.navigate(R.id.navigation_archived_task) } } return true } override fun onDestroy() { super.onDestroy() _binding = null } @StringRes private fun getToolbarTitle(): Int { return when (viewModel.filterOption) { TaskViewModel.Constraint.ALL -> R.string.activity_tasks TaskViewModel.Constraint.PENDING -> R.string.activity_tasks_pending TaskViewModel.Constraint.FINISHED -> R.string.activity_tasks_finished } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/TaskPackage.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task import android.os.Parcelable import androidx.recyclerview.widget.DiffUtil import androidx.room.Embedded import androidx.room.Relation import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.subject.Subject import kotlinx.android.parcel.Parcelize /** * Data class used for data representation * of tasks, subjects and its attachments * in the UI */ @Parcelize data class TaskPackage( @Embedded var task: Task, @Embedded var subject: Subject? = null, @Relation(entity = Attachment::class, parentColumn = "taskID", entityColumn = "task") var attachments: List = emptyList() ) : Parcelable { companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: TaskPackage, newItem: TaskPackage): Boolean { return oldItem.task?.taskID == newItem.task?.taskID } override fun areContentsTheSame(oldItem: TaskPackage, newItem: TaskPackage): Boolean { return oldItem == newItem } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/TaskViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task import androidx.lifecycle.* import com.isaiahvonrundstedt.fokus.components.enums.SortDirection import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class TaskViewModel @Inject constructor( private val repository: TaskRepository, private val preferenceManager: PreferenceManager ) : ViewModel() { val _tasks: LiveData> = repository.fetchLiveData() val tasks: MediatorLiveData> = MediatorLiveData() val isEmpty: LiveData = Transformations.map(tasks) { it.isNullOrEmpty() } var filterOption = preferenceManager.taskConstraint set(value) { field = value preferenceManager.taskConstraint = value rearrange(value, sort, sortDirection) } var sort: Sort = preferenceManager.tasksSort set(value) { field = value rearrange(filterOption, value, sortDirection) } var sortDirection: SortDirection = preferenceManager.tasksSortDirection set(value) { field = value rearrange(filterOption, sort, value) } init { tasks.addSource(_tasks) { items -> when (filterOption) { Constraint.ALL -> tasks.value = items Constraint.PENDING -> tasks.value = items.filter { !it.task.isFinished } Constraint.FINISHED -> tasks.value = items.filter { it.task.isFinished } } } } fun insert(task: Task, attachmentList: List = emptyList()) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.insert(task, attachmentList) } fun remove(task: Task) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.remove(task) } fun update(task: Task, attachmentList: List = emptyList()) = viewModelScope.launch(Dispatchers.IO + NonCancellable) { repository.update(task, attachmentList) } private fun rearrange(filter: Constraint, sort: Sort, direction: SortDirection) = when (filter) { Constraint.ALL -> { _tasks.value?.let { items -> tasks.value = when (sort) { Sort.NAME -> { when (direction) { SortDirection.ASCENDING -> items.sortedBy { it.task.name } SortDirection.DESCENDING -> items.sortedByDescending { it.task.name } } } Sort.DUE -> { when (direction) { SortDirection.ASCENDING -> items.sortedBy { it.task.dueDate } SortDirection.DESCENDING -> items.sortedByDescending { it.task.dueDate } } } } } } Constraint.PENDING -> { _tasks.value?.let { items -> tasks.value = when (sort) { Sort.NAME -> { when (direction) { SortDirection.ASCENDING -> items.filter { !it.task.isFinished } .sortedBy { it.task.name } SortDirection.DESCENDING -> items.filter { !it.task.isFinished } .sortedByDescending { it.task.name } } } Sort.DUE -> { when (direction) { SortDirection.ASCENDING -> items.filter { !it.task.isFinished } .sortedBy { it.task.dueDate } SortDirection.DESCENDING -> items.filter { !it.task.isFinished } .sortedByDescending { it.task.dueDate } } } } } } Constraint.FINISHED -> { _tasks.value?.let { items -> tasks.value = when (sort) { Sort.NAME -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.task.isFinished } .sortedBy { it.task.name } SortDirection.DESCENDING -> items.filter { it.task.isFinished } .sortedByDescending { it.task.name } } } Sort.DUE -> { when (direction) { SortDirection.ASCENDING -> items.filter { it.task.isFinished } .sortedBy { it.task.dueDate } SortDirection.DESCENDING -> items.filter { it.task.isFinished } .sortedByDescending { it.task.dueDate } } } } } } } enum class Sort { NAME, DUE; companion object { fun parse(value: String): Sort { return when (value) { NAME.toString() -> NAME DUE.toString() -> DUE else -> NAME } } } } enum class Constraint { ALL, PENDING, FINISHED; companion object { fun parse(value: String): Constraint { return when (value) { ALL.toString() -> ALL PENDING.toString() -> PENDING FINISHED.toString() -> FINISHED else -> PENDING } } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/archived/ArchivedTaskAdapter.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.archived import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.getCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setCompoundDrawableAtStart import com.isaiahvonrundstedt.fokus.components.extensions.android.setStrikeThroughEffect import com.isaiahvonrundstedt.fokus.components.extensions.android.setTextColorFromResource import com.isaiahvonrundstedt.fokus.databinding.LayoutItemArchivedTaskBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.task.TaskPackage class ArchivedTaskAdapter(private val listener: SelectListener) : BaseAdapter(TaskPackage.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArchivedTaskViewHolder { val binding = LayoutItemArchivedTaskBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ArchivedTaskViewHolder(binding.root, listener) } override fun onBindViewHolder(holder: ArchivedTaskViewHolder, position: Int) { holder.onBind(getItem(position)) } class ArchivedTaskViewHolder(itemView: View, private val listener: SelectListener) : BaseViewHolder(itemView) { private val binding = LayoutItemArchivedTaskBinding.bind(itemView) override fun onBind(data: T) { if (data is TaskPackage) { with(data.task) { binding.root.transitionName = BaseFragment.TRANSITION_ELEMENT_ROOT + taskID val textColorRes = if (isFinished) R.color.color_secondary_text else R.color.color_primary_text binding.taskNameView.text = name binding.taskNameView.setTextColorFromResource(textColorRes) binding.taskNameView.setStrikeThroughEffect(isFinished) if (hasDueDate()) binding.dueDateView.text = formatDueDate(binding.root.context) else binding.dueDateView.isVisible = false } binding.root.setOnClickListener { listener.onItemSelected(data) } if (data.subject != null) { with(binding.subjectView) { text = data.subject?.code setCompoundDrawableAtStart( data.subject?.tintDrawable( getCompoundDrawableAtStart() ) ) } } else binding.subjectView.isVisible = false } } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/archived/ArchivedTaskFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.archived import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation import androidx.recyclerview.widget.LinearLayoutManager import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.custom.ItemDecoration import com.isaiahvonrundstedt.fokus.databinding.FragmentArchivedTaskBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseAdapter import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseFragment import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ArchivedTaskFragment : BaseFragment(), BaseAdapter.SelectListener { private var _binding: FragmentArchivedTaskBinding? = null private var controller: NavController? = null private val archivedTaskAdapter = ArchivedTaskAdapter(this) private val viewModel: ArchivedTaskViewModel by viewModels() private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentArchivedTaskBinding.inflate(layoutInflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.recyclerView, binding.emptyView)) controller = Navigation.findNavController(view) with(binding.appBarLayout.toolbar) { setTitle(R.string.activity_archives) setNavigationIcon(R.drawable.ic_outline_arrow_back_24) setNavigationOnClickListener { controller?.navigateUp() } } with(binding.recyclerView) { addItemDecoration(ItemDecoration(context)) layoutManager = LinearLayoutManager(context) adapter = archivedTaskAdapter } } override fun onStart() { super.onStart() viewModel.items.observe(viewLifecycleOwner) { archivedTaskAdapter.submitList(it) } viewModel.isEmpty.observe(viewLifecycleOwner) { binding.emptyView.isVisible = it } } override fun onItemSelected(t: T) { if (t is TaskPackage) { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.dialog_confirm_unarchive_title) message(R.string.dialog_confirm_unarchive_summary) positiveButton { viewModel.removeFromArchive(t) } negativeButton(R.string.button_cancel) } } } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/archived/ArchivedTaskViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.archived import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArchivedTaskViewModel @Inject constructor( private val taskRepository: TaskRepository ) : ViewModel() { val items: LiveData> = taskRepository.fetchArchived() val isEmpty: LiveData = Transformations.map(items) { it.isEmpty() } fun removeFromArchive(taskPackage: TaskPackage) = viewModelScope.launch { taskPackage.task.isTaskArchived = false taskRepository.update(taskPackage.task) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/editor/TaskEditorContainer.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.editor import android.os.Bundle import com.isaiahvonrundstedt.fokus.databinding.ActivityContainerTaskBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseActivity import dagger.hilt.android.AndroidEntryPoint /** * This activity acts as a container * for the editor fragment. This is * used when needing to show the * editor ui without needing a fragment * transaction. */ @AndroidEntryPoint class TaskEditorContainer : BaseActivity() { private lateinit var binding: ActivityContainerTaskBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityContainerTaskBinding.inflate(layoutInflater) setContentView(binding.root) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/editor/TaskEditorFragment.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.editor import android.Manifest import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.os.Bundle import android.text.format.DateFormat.is24HourFormat import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.browser.customtabs.CustomTabsIntent import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.core.view.isVisible import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.viewModels import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.navigation.NavController import androidx.navigation.Navigation import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.callbacks.onShow import com.afollestad.materialdialogs.checkbox.checkBoxPrompt import com.afollestad.materialdialogs.customview.customView import com.afollestad.materialdialogs.datetime.dateTimePicker import com.afollestad.materialdialogs.lifecycle.lifecycleOwner import com.afollestad.materialdialogs.utils.MDUtil.textChanged import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.isaiahvonrundstedt.fokus.Fokus import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.* import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toArrayList import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toCalendar import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toZonedDateTime import com.isaiahvonrundstedt.fokus.components.interfaces.Streamable import com.isaiahvonrundstedt.fokus.components.service.DataExporterService import com.isaiahvonrundstedt.fokus.components.service.DataImporterService import com.isaiahvonrundstedt.fokus.components.service.FileImporterService import com.isaiahvonrundstedt.fokus.components.utils.PermissionManager import com.isaiahvonrundstedt.fokus.components.utils.PreferenceManager import com.isaiahvonrundstedt.fokus.components.views.RadioButtonCompat import com.isaiahvonrundstedt.fokus.components.views.TwoLineRadioButton import com.isaiahvonrundstedt.fokus.databinding.FragmentEditorTaskBinding import com.isaiahvonrundstedt.fokus.databinding.LayoutDialogInputAttachmentBinding import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.attachments.AttachmentOptionSheet import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.schedule.picker.SchedulePickerSheet import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseEditorFragment import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseService import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.subject.SubjectPackage import com.isaiahvonrundstedt.fokus.features.subject.picker.SubjectPickerFragment import com.isaiahvonrundstedt.fokus.features.task.Task import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import com.isaiahvonrundstedt.fokus.features.viewer.ImageViewer import dagger.hilt.android.AndroidEntryPoint import me.saket.cascade.overrideOverflowMenu import java.io.File import javax.inject.Inject @AndroidEntryPoint class TaskEditorFragment : BaseEditorFragment(), FragmentResultListener { private var _binding: FragmentEditorTaskBinding? = null private var controller: NavController? = null private var requestKey = REQUEST_KEY_INSERT private val viewModel: TaskEditorViewModel by viewModels() private val binding get() = _binding!! private lateinit var permissionLauncher: ActivityResultLauncher private lateinit var attachmentLauncher: ActivityResultLauncher private lateinit var exportLauncher: ActivityResultLauncher private lateinit var importLauncher: ActivityResultLauncher @Inject lateinit var permissionManager: PermissionManager @Inject lateinit var preferenceManager: PreferenceManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedElementEnterTransition = buildContainerTransform() sharedElementReturnTransition = buildContainerTransform() permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { triggerSystemFilePickerForAttachment() } else { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.dialog_permission_needed_title) message(R.string.dialog_permission_needed_summary) positiveButton(R.string.button_continue) {} negativeButton(R.string.button_cancel) } } } attachmentLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { val attachmentId = Attachment.generateId() context?.startService(Intent(context, FileImporterService::class.java).apply { action = FileImporterService.ACTION_START data = it.data?.data putExtra(FileImporterService.EXTRA_OBJECT_ID, attachmentId) }) } } exportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { context?.startService(Intent(context, DataExporterService::class.java).apply { this.data = it.data?.data action = DataExporterService.ACTION_EXPORT_TASK putExtra(DataExporterService.EXTRA_EXPORT_SOURCE, viewModel.getTask()) putExtra( DataExporterService.EXTRA_EXPORT_DEPENDENTS, viewModel.getAttachments() ) }) } } importLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) { context?.startService(Intent(context, DataImporterService::class.java).apply { this.data = it.data?.data action = DataImporterService.ACTION_IMPORT_TASK }) } } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentEditorTaskBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.coordinator.transitionName = TRANSITION_ELEMENT_ROOT controller = Navigation.findNavController(view) setInsets(binding.root, binding.appBarLayout.toolbar, arrayOf(binding.contentView), binding.actionButton) arguments?.getBundle(EXTRA_TASK)?.also { requestKey = REQUEST_KEY_UPDATE Task.fromBundle(it)?.also { task -> viewModel.setTask(task) binding.root.transitionName = TRANSITION_ELEMENT_ROOT + task.taskID binding.priorityCard.isVisible = task.isFinished } } arguments?.getParcelableArrayList(EXTRA_ATTACHMENTS)?.also { viewModel.setAttachments(it) } arguments?.getBundle(EXTRA_SUBJECT)?.also { viewModel.setSubject(Subject.fromBundle(it)) } with(binding.appBarLayout.toolbar) { inflateMenu(R.menu.menu_editor) setNavigationOnClickListener { if (controller?.graph?.id == R.id.navigation_container_task) requireActivity().finish() else controller?.navigateUp() } overrideOverflowMenu(::customPopupProvider) setOnMenuItemClickListener(::onMenuItemClicked) } registerForFragmentResult( arrayOf( SubjectPickerFragment.REQUEST_KEY_PICK, SchedulePickerSheet.REQUEST_KEY, AttachmentOptionSheet.REQUEST_KEY ), this ) } private val dialogView: View by lazy { LayoutDialogInputAttachmentBinding.inflate(layoutInflater).root } override fun onStart() { super.onStart() LocalBroadcastManager.getInstance(requireContext()) .registerReceiver(receiver, IntentFilter(BaseService.ACTION_SERVICE_BROADCAST)) if (requestKey == REQUEST_KEY_UPDATE) { binding.taskNameTextInput.setText(viewModel.getName()) binding.notesTextInput.setText(viewModel.getNotes()) } viewModel.task.observe(this) { if (requestKey == REQUEST_KEY_UPDATE && it != null) { with(it) { binding.prioritySwitch.isChecked = isImportant binding.statusSwitch.isChecked = isFinished binding.removeDueDateButton.isVisible = it.hasDueDate() if (it.hasDueDate()) { binding.dueDateTextView.text = formatDueDate(requireContext()) binding.dueDateTextView.setTextColorFromResource(R.color.color_primary_text) } else { binding.dueDateTextView.setText(R.string.field_due_date) binding.dueDateTextView.setTextColorFromResource(R.color.color_secondary_text) } } } } viewModel.attachments.observe(this) { binding.attachmentsChipGroup.removeAllViews() it.forEach { attachment -> binding.attachmentsChipGroup.addView(Chip(requireContext()).apply { text = attachment.name tag = attachment.attachmentID isCloseIconVisible = true setCloseIconResource(R.drawable.ic_outline_close_24) setOnClickListener { if (attachment.target != null) onParseForIntent(attachment) } setOnCloseIconClickListener { val uri = Uri.parse(attachment.target) MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title( text = String.format( getString(R.string.dialog_confirm_deletion_title), attachment.name ) ) message(R.string.dialog_confirm_deletion_summary) positiveButton(R.string.button_delete) { viewModel.removeAttachment(attachment) when (attachment.type) { Attachment.TYPE_CONTENT_URI -> context.contentResolver.delete(uri, null, null) Attachment.TYPE_IMPORTED_FILE -> File(attachment.target!!).delete() } } negativeButton(R.string.button_cancel) } } }) } } viewModel.subject.observe(this) { binding.removeButton.isVisible = it != null binding.dateTimeRadioGroup.isVisible = it != null binding.dueDateTextView.isVisible = it == null binding.removeDueDateButton.isVisible = it == null && viewModel.getDueDate() != null if (it != null) { with(binding.subjectTextView) { text = it.code setTextColorFromResource(R.color.color_primary_text) setCompoundDrawableAtStart(ContextCompat.getDrawable( context, R.drawable.shape_color_holder )?.let { shape -> it.tintDrawable(shape) }) } if (viewModel.getDueDate() != null) { with(binding.customDateTimeRadio) { isChecked = true titleTextColor = ContextCompat.getColor(context, R.color.color_primary_text) subtitle = viewModel.getTask()?.formatDueDate(context) } } } else { with(binding.subjectTextView) { setText(R.string.field_subject) setTextColorFromResource(R.color.color_secondary_text) removeCompoundDrawableAtStart() } if (viewModel.getDueDate() != null) { with(binding.dueDateTextView) { text = viewModel.getTask()?.formatDueDate(context) setTextColorFromResource(R.color.color_primary_text) } } } } viewModel.isNameExists.observe(this) { binding.taskNameTextInputLayout.error = if (it) getString(R.string.feedback_task_name_exists) else null } binding.taskNameTextInput.textChanged { viewModel.checkNameUniqueness(it.toString()) } binding.taskNameTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setName(v.text.toString()) } } binding.notesTextInput.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus && v is TextInputEditText) { viewModel.setNotes(v.text.toString()) } } binding.addActionChip.setOnClickListener { hideKeyboardFromCurrentFocus(requireView()) AttachmentOptionSheet.show(childFragmentManager) } binding.dueDateTextView.setOnClickListener { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) dateTimePicker( requireFutureDateTime = true, currentDateTime = viewModel.getDueDate()?.toCalendar(), show24HoursView = is24HourFormat(requireContext()) ) { _, datetime -> viewModel.setDueDate(datetime.toZonedDateTime()) binding.removeDueDateButton.isVisible = true } positiveButton(R.string.button_done) { with(binding.dueDateTextView) { text = viewModel.getTask()?.formatDueDate(context) setTextColorFromResource(R.color.color_primary_text) } } } } binding.removeDueDateButton.setOnClickListener { viewModel.setDueDate(null) binding.removeDueDateButton.isVisible = false binding.dueDateTextView.setText(R.string.field_due_date) binding.dueDateTextView.setTextColor( ContextCompat.getColor( it.context, R.color.color_secondary_text ) ) } binding.subjectTextView.setOnClickListener { SubjectPickerFragment(childFragmentManager) .show() } binding.removeButton.setOnClickListener { binding.dueDateTextView.setText(R.string.field_due_date) binding.dueDateTextView.setTextColor( ContextCompat.getColor( it.context, R.color.color_secondary_text ) ) binding.subjectTextView.startAnimation(animation) it.isVisible = false viewModel.setSubject(null) } // When a radio button has been checked, set the other // radio buttons text color to colorSecondaryText binding.dateTimeRadioGroup.setOnCheckedChangeListener { radioGroup, _ -> for (v: View in radioGroup.children) { if (v is TwoLineRadioButton && !v.isChecked) { v.titleTextColor = ContextCompat.getColor( v.context, R.color.color_secondary_text ) v.subtitle = null } else if (v is RadioButtonCompat && !v.isChecked) { v.setTextColor( ContextCompat.getColor( v.context, R.color.color_secondary_text ) ) } } } binding.statusSwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setFinished(isChecked) } binding.prioritySwitch.setOnCheckedChangeListener { _, isChecked -> viewModel.setImportant(isChecked) binding.priorityCard.isVisible = isChecked } binding.noDueRadioButton.setOnClickListener { viewModel.setDueDate(null) binding.dueDateTextView.setText(R.string.field_not_set) (it as RadioButtonCompat).setTextColor( ContextCompat.getColor( it.context, R.color.color_primary_text ) ) } binding.inNextMeetingRadio.setOnClickListener { viewModel.setNextMeetingForDueDate() with(binding.inNextMeetingRadio) { titleTextColor = ContextCompat.getColor(context, R.color.color_primary_text) subtitle = viewModel.getTask()?.formatDueDate(context) } } binding.pickDateTimeRadio.setOnClickListener { hideKeyboardFromCurrentFocus(requireView()) SchedulePickerSheet .show(viewModel.schedules, childFragmentManager) } binding.customDateTimeRadio.setOnClickListener { MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) dateTimePicker( requireFutureDateTime = true, currentDateTime = viewModel.getDueDate()?.toCalendar(), show24HoursView = is24HourFormat(requireContext()) ) { _, datetime -> viewModel.setDueDate(datetime.toZonedDateTime()) } positiveButton(R.string.button_done) { with(binding.customDateTimeRadio) { titleTextColor = ContextCompat.getColor( context, R.color.color_primary_text ) subtitle = viewModel.getTask()?.formatDueDate(context) } } negativeButton { binding.customDateTimeRadio.isChecked = false } } } binding.actionButton.setOnClickListener { hideKeyboardFromCurrentFocus(requireView()) viewModel.setName(binding.taskNameTextInput.text.toString()) viewModel.setNotes(binding.notesTextInput.text.toString()) // These if checks if the user have entered the // values on the fields, if we don't have the value required, // show a snackbar feedback then direct the user's // attention to the field. Then return to stop the execution // of the code. if (viewModel.getName()?.isEmpty() == true) { createSnackbar(R.string.feedback_task_empty_name, binding.root) binding.taskNameTextInput.requestFocus() return@setOnClickListener } if (requestKey == REQUEST_KEY_INSERT) viewModel.insert() else viewModel.update() if (controller?.graph?.id == R.id.navigation_container_task) requireActivity().finish() else controller?.navigateUp() } } private fun triggerSystemFilePickerForAttachment() { attachmentLauncher.launch( Intent(Intent.ACTION_OPEN_DOCUMENT) .setType("*/*") ) } override fun onStop() { super.onStop() /** * Check if the soft keyboard is visible * then hide it before the user leaves * the fragment */ hideKeyboardFromCurrentFocus(requireView()) } override fun onDestroy() { _binding = null LocalBroadcastManager.getInstance(requireContext()) .unregisterReceiver(receiver) // Cancel all current import processes context?.startService(Intent(context, FileImporterService::class.java).apply { action = FileImporterService.ACTION_CANCEL }) super.onDestroy() } private var receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == BaseService.ACTION_SERVICE_BROADCAST) { when (intent.getStringExtra(BaseService.EXTRA_BROADCAST_STATUS)) { FileImporterService.BROADCAST_IMPORT_ONGOING -> { createSnackbar( R.string.feedback_import_ongoing, binding.root, Snackbar.LENGTH_INDEFINITE ) } FileImporterService.BROADCAST_IMPORT_COMPLETED -> { createSnackbar(R.string.feedback_import_completed, binding.root) if (intent.hasExtra(BaseService.EXTRA_BROADCAST_DATA) && intent.hasExtra(FileImporterService.EXTRA_BROADCAST_EXTRA) ) { val id = intent.getStringExtra(BaseService.EXTRA_BROADCAST_DATA) val name = intent.getStringExtra(FileImporterService.EXTRA_BROADCAST_EXTRA) val extension = File(name!!).extension val attachment = Attachment( attachmentID = id!!, name = name, target = File( Attachment.getTargetDirectory(context), "${id}.${extension}" ).name, type = Attachment.TYPE_IMPORTED_FILE, task = viewModel.getID()!! ) viewModel.addAttachment(attachment) binding.appBarLayout.toolbar .menu?.findItem(R.id.action_share_options)?.isVisible = !viewModel.hasFileAttachment() } } FileImporterService.BROADCAST_IMPORT_FAILED -> { createSnackbar(R.string.feedback_import_failed, binding.root) } DataExporterService.BROADCAST_EXPORT_ONGOING -> { createSnackbar(R.string.feedback_export_ongoing, binding.root) } DataExporterService.BROADCAST_EXPORT_COMPLETED -> { createSnackbar(R.string.feedback_export_completed, binding.root) intent.getStringExtra(BaseService.EXTRA_BROADCAST_DATA)?.also { val uri = Fokus.obtainUriForFile(requireContext(), File(it)) startActivity( ShareCompat.IntentBuilder.from(requireActivity()) .addStream(uri) .setType(Streamable.MIME_TYPE_ZIP) .setChooserTitle(R.string.dialog_send_to) .intent ) } } DataExporterService.BROADCAST_EXPORT_FAILED -> { createSnackbar(R.string.feedback_export_failed, binding.root) } DataImporterService.BROADCAST_IMPORT_ONGOING -> { createSnackbar(R.string.feedback_import_ongoing, binding.root) } DataImporterService.BROADCAST_IMPORT_COMPLETED -> { createSnackbar(R.string.feedback_import_completed, binding.root) intent.getParcelableExtra(BaseService.EXTRA_BROADCAST_DATA) ?.also { viewModel.setTask(it.task) viewModel.setAttachments(ArrayList(it.attachments)) } } DataImporterService.BROADCAST_IMPORT_FAILED -> { createSnackbar(R.string.feedback_import_failed, binding.root) } } } } } private fun onMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.action_export -> { val fileName = getSharingName() if (fileName == null) { MaterialDialog(requireContext()).show { title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_done) { dismiss() } } return false } /** * Make the user specify where to save * the exported file */ exportLauncher.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, fileName) type = Streamable.MIME_TYPE_ZIP }) } R.id.action_share -> { val fileName = getSharingName() if (fileName == null) { MaterialDialog(requireContext()).show { title(R.string.feedback_unable_to_share_title) message(R.string.feedback_unable_to_share_message) positiveButton(R.string.button_done) { dismiss() } } return false } /** * We need first to export the data as a raw file * then pass it onto the system sharing component */ context?.startService(Intent(context, DataExporterService::class.java).apply { action = DataExporterService.ACTION_EXPORT_TASK putExtra( DataExporterService.EXTRA_EXPORT_SOURCE, viewModel.getTask() ) putExtra( DataExporterService.EXTRA_EXPORT_DEPENDENTS, viewModel.getAttachments() ) }) } R.id.action_import -> { val chooser = Intent.createChooser(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { type = Streamable.MIME_TYPE_ZIP }, getString(R.string.dialog_select_file_import)) importLauncher.launch(chooser) } } return true } override fun onFragmentResult(requestKey: String, result: Bundle) { when (requestKey) { SubjectPickerFragment.REQUEST_KEY_PICK -> { result.getParcelable(SubjectPickerFragment.EXTRA_SELECTED_SUBJECT)?.let { viewModel.setSubject(it.subject) viewModel.schedules = it.schedules.toArrayList() } } SchedulePickerSheet.REQUEST_KEY -> { result.getParcelable(SchedulePickerSheet.EXTRA_SCHEDULE)?.also { viewModel.setClassScheduleAsDueDate(it) with(binding.pickDateTimeRadio) { titleTextColor = ContextCompat.getColor( context, R.color.color_primary_text ) subtitle = viewModel.getTask()?.formatDueDate(context) } } } AttachmentOptionSheet.REQUEST_KEY -> { result.getInt(AttachmentOptionSheet.EXTRA_OPTION).also { when (it) { R.id.action_import_file -> { // Check if we have read storage permissions then request the permission // if we have the permission, open up file picker if (permissionManager.readStorageGranted) { if (!preferenceManager.noConfirmImport) MaterialDialog(requireContext()).show { lifecycleOwner(viewLifecycleOwner) title(R.string.dialog_import_attachment_title) message(R.string.dialog_import_attachment_summary) checkBoxPrompt(R.string.dialog_import_attachment_confirm) { isChecked -> preferenceManager.noConfirmImport = isChecked } positiveButton(R.string.button_continue) { triggerSystemFilePickerForAttachment() } negativeButton(R.string.button_cancel) } else triggerSystemFilePickerForAttachment() } else permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) } R.id.action_website_url -> { var attachment: Attachment? MaterialDialog(requireContext()).show { title(res = R.string.dialog_enter_website_url) customView(view = dialogView) positiveButton(R.string.button_done) { val binding = LayoutDialogInputAttachmentBinding.bind(it.view) attachment = Attachment( target = binding.editText.text.toString(), name = binding.editText.text.toString(), type = Attachment.TYPE_WEBSITE_LINK, task = viewModel.getID()!! ) attachment?.also { item -> viewModel.addAttachment(item) } } onShow { val webLink = viewModel.fetchRecentItemFromClipboard() if (webLink.startsWith("https://") || webLink.startsWith("http://") || webLink.startsWith("www") ) { val binding = LayoutDialogInputAttachmentBinding.bind(it.view) binding.editText.setText(webLink) } } negativeButton(R.string.button_cancel) } } } } } } } // This function invokes the corresponding application that // will open the uri of the attachment if the user clicks // on the attachment item private fun onParseForIntent(attachment: Attachment) { when (attachment.type) { Attachment.TYPE_CONTENT_URI -> { val targetUri: Uri = Uri.parse(attachment.target) val intent = Intent(Intent.ACTION_VIEW) .setDataAndType(targetUri, requireContext().contentResolver?.getType(targetUri)) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) if (intent.resolveActivity(context?.packageManager!!) != null) startActivity(intent) } Attachment.TYPE_IMPORTED_FILE -> { val file = File(Attachment.getTargetDirectory(context), attachment.target!!) when { Attachment.isImage(file.path) -> { ImageViewer.show(childFragmentManager, file.path, attachment.name) } else -> { val targetUri = Fokus.obtainUriForFile( requireContext(), file ) val intent = Intent(Intent.ACTION_VIEW) .setDataAndType( targetUri, requireContext().contentResolver?.getType(targetUri) ) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) if (intent.resolveActivity(context?.packageManager!!) != null) startActivity(intent) } } } Attachment.TYPE_WEBSITE_LINK -> { var targetPath: String? = attachment.target if (attachment.target?.startsWith("http://") != true && attachment.target?.startsWith("https://") != true ) targetPath = "http://$targetPath" val targetUri: Uri = Uri.parse(targetPath) if (PreferenceManager(requireContext()).useExternalBrowser) { val intent = Intent(Intent.ACTION_VIEW, targetUri) if (intent.resolveActivity(context?.packageManager!!) != null) startActivity(intent) } else CustomTabsIntent.Builder().build() .launchUrl(requireContext(), targetUri) } } } private fun getSharingName(): String? { return when (requestKey) { REQUEST_KEY_INSERT -> { if (viewModel.getName()?.isEmpty() == true || viewModel.getDueDate() == null ) { return null } binding.taskNameTextInput.text.toString() } REQUEST_KEY_UPDATE -> { viewModel.getName() ?: Streamable.ARCHIVE_NAME_GENERIC } else -> null } } override fun onSaveInstanceState(outState: Bundle) { with(outState) { putParcelable(EXTRA_TASK, viewModel.getTask()) putParcelable(EXTRA_SUBJECT, viewModel.getSubject()) putParcelableArrayList(EXTRA_ATTACHMENTS, ArrayList(viewModel.getAttachments())) } super.onSaveInstanceState(outState) } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) savedInstanceState?.run { viewModel.setTask(getParcelable(EXTRA_TASK)) viewModel.setAttachments(getParcelableArrayList(EXTRA_ATTACHMENTS) ?: arrayListOf()) viewModel.setSubject(getParcelable(EXTRA_SUBJECT)) } } companion object { const val REQUEST_KEY_INSERT = "request:insert" const val REQUEST_KEY_UPDATE = "request:update" const val EXTRA_TASK = "extra:task" const val EXTRA_SUBJECT = "extra:subject" const val EXTRA_ATTACHMENTS = "extra:attachments" } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/editor/TaskEditorViewModel.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.editor import android.content.ClipboardManager import androidx.lifecycle.* import com.isaiahvonrundstedt.fokus.components.extensions.jdk.toArrayList import com.isaiahvonrundstedt.fokus.database.dao.ScheduleDAO import com.isaiahvonrundstedt.fokus.database.repository.TaskRepository import com.isaiahvonrundstedt.fokus.features.attachments.Attachment import com.isaiahvonrundstedt.fokus.features.schedule.Schedule import com.isaiahvonrundstedt.fokus.features.subject.Subject import com.isaiahvonrundstedt.fokus.features.task.Task import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import java.time.LocalDate import java.time.ZonedDateTime import javax.inject.Inject @HiltViewModel class TaskEditorViewModel @Inject constructor( private val clipboardManager: ClipboardManager, private val scheduleDao: ScheduleDAO, private val repository: TaskRepository ) : ViewModel() { private val _task: MutableLiveData = MutableLiveData(Task()) private val _attachments: MutableLiveData> = MutableLiveData(arrayListOf()) private val _subject: MutableLiveData = MutableLiveData(null) private val _isNameExists: MutableLiveData = MutableLiveData(false) val task: LiveData = _task val attachments: LiveData> = Transformations.map(_attachments) { it.toList() } val subject: LiveData = _subject val isNameExists: LiveData = _isNameExists var schedules: ArrayList = arrayListOf() fun getTask(): Task? { return task.value } fun setTask(task: Task?) { _task.value = task } fun getSubject(): Subject? { return subject.value } fun setSubject(subject: Subject?) { _subject.value = subject if (subject != null) { fetchSchedulesFromDatabase(subject.subjectID) setTaskSubjectID(subject.subjectID) } else { schedules.clear() setTaskSubjectID(null) } } fun getAttachments(): List { return attachments.value ?: emptyList() } fun setAttachments(items: ArrayList) { _attachments.value = items } fun addAttachment(attachment: Attachment) { val items = ArrayList(getAttachments()) items.add(attachment) setAttachments(items.distinctBy { it.attachmentID }.toArrayList()) } fun removeAttachment(attachment: Attachment) { val items = ArrayList(getAttachments()) items.remove(attachment) setAttachments(items.distinctBy { it.attachmentID }.toArrayList()) } fun checkNameUniqueness(name: String?) = viewModelScope.launch { val result = repository.checkNameUniqueness(name, getTask()?.taskID) _isNameExists.value = !result.contains(name) && result.isNotEmpty() } fun getID(): String? { return getTask()?.taskID } fun getName(): String? { return getTask()?.name } fun setName(name: String?) { // Check if the same value is being set if (name == getName()) return val task = getTask() task?.name = name setTask(task) } fun getDueDate(): ZonedDateTime? { return getTask()?.dueDate } fun setDueDate(dueDate: ZonedDateTime?) { val task = getTask() task?.dueDate = dueDate setTask(task) } fun getTaskSubjectID(): String? { return getTask()?.subject } fun setTaskSubjectID(id: String?) { val task = getTask() task?.subject = id setTask(task) } fun getImportant(): Boolean { return getTask()?.isImportant == true } fun setImportant(isImportant: Boolean) { val task = getTask() task?.isImportant = isImportant setTask(task) } fun getFinished(): Boolean { return getTask()?.isFinished == true } fun setFinished(isFinished: Boolean) { val task = getTask() task?.isFinished = isFinished setTask(task) } fun getNotes(): String? { return getTask()?.notes } fun setNotes(notes: String?) { // Check if the same value is being set if (notes == getNotes()) return val task = getTask() task?.notes = notes setTask(task) } fun hasFileAttachment(): Boolean { return getAttachments().any { it.type != Attachment.TYPE_WEBSITE_LINK } } fun fetchRecentItemFromClipboard(): String = clipboardManager.primaryClip?.getItemAt(0)?.text.toString() fun setNextMeetingForDueDate() { setDueDate(getDateTimeForNextMeeting()) } fun setClassScheduleAsDueDate(schedule: Schedule) { setDueDate(schedule.startTime?.let { Schedule.getNearestDateTime(schedule.daysOfWeek, it) }) } private fun getDateTimeForNextMeeting(): ZonedDateTime? { val currentDate = LocalDate.now() val individualDates = mutableListOf() // Create new instance of schedule with // one day of week each schedules.forEach { it.parseDaysOfWeek().forEach { day -> val newSchedule = Schedule( startTime = it.startTime, endTime = it.endTime ) newSchedule.daysOfWeek = day individualDates.add(newSchedule) } } // Map the schedules to their respective // dateTime instances val dates = individualDates.map { it.startTime?.let { time -> Schedule.getNearestDateTime(it.daysOfWeek, time) } } if (dates.isEmpty()) return null return dates.singleOrNull { currentDate.isAfter(it?.toLocalDate()) || currentDate.isEqual(it?.toLocalDate()) } ?: dates[0] } private fun fetchSchedulesFromDatabase(id: String) = viewModelScope.launch { schedules.addAll(scheduleDao.fetchUsingID(id)) } fun insert() = viewModelScope.launch(Dispatchers.IO + NonCancellable) { getTask()?.let { repository.insert(it, getAttachments()) } } fun update() = viewModelScope.launch(Dispatchers.IO + NonCancellable) { getTask()?.let { repository.update(it, getAttachments()) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/widget/TaskWidgetProvider.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.widget import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent import android.widget.RemoteViews import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.task.editor.TaskEditorContainer class TaskWidgetProvider : AppWidgetProvider() { override fun onReceive(context: Context?, intent: Intent?) { super.onReceive(context, intent) if (intent?.action == WIDGET_ACTION_UPDATE) { val manager = AppWidgetManager.getInstance(context) val component = ComponentName(context!!, TaskWidgetProvider::class.java) manager.notifyAppWidgetViewDataChanged( manager.getAppWidgetIds(component), R.id.listView ) } } override fun onUpdate( context: Context?, appWidgetManager: AppWidgetManager?, appWidgetIds: IntArray? ) { super.onUpdate(context, appWidgetManager, appWidgetIds) appWidgetIds?.forEach { onUpdateWidget(context, appWidgetManager, it) } } private fun onUpdateWidget(context: Context?, manager: AppWidgetManager?, id: Int) { val mainIntent = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NAVIGATION_TASK }, PendingIntent.FLAG_IMMUTABLE ) val itemIntent = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_WIDGET_TASK }, PendingIntent.FLAG_IMMUTABLE ) val addIntent = PendingIntent.getActivity( context, 0, Intent(context, TaskEditorContainer::class.java), PendingIntent.FLAG_IMMUTABLE ) val views = RemoteViews(context?.packageName, R.layout.layout_widget_tasks) with(views) { setOnClickPendingIntent(R.id.rootView, mainIntent) setRemoteAdapter(R.id.listView, Intent(context, TaskWidgetService::class.java)) setPendingIntentTemplate(R.id.listView, itemIntent) setEmptyView(R.id.listView, R.id.emptyView) setOnClickPendingIntent(R.id.actionButton, addIntent) } manager?.notifyAppWidgetViewDataChanged(id, R.id.listView) manager?.updateAppWidget(id, views) } companion object { private const val WIDGET_ACTION_UPDATE = "widget:task:update" fun triggerRefresh(context: Context?) { context?.sendBroadcast(Intent(WIDGET_ACTION_UPDATE).apply { component = ComponentName(context, TaskWidgetProvider::class.java) }) } } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/widget/TaskWidgetRemoteViewFactory.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.widget import android.content.Context import android.content.Intent import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService import com.isaiahvonrundstedt.fokus.R import com.isaiahvonrundstedt.fokus.components.extensions.android.putExtra import com.isaiahvonrundstedt.fokus.database.AppDatabase import com.isaiahvonrundstedt.fokus.features.core.activities.MainActivity import com.isaiahvonrundstedt.fokus.features.task.TaskPackage import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking class TaskWidgetRemoteViewFactory(private var context: Context) : RemoteViewsService.RemoteViewsFactory { private var itemList = mutableListOf() private fun fetch() { itemList.clear() val tasks = AppDatabase.getInstance(context).tasks() var items = emptyList() runBlocking { val job = async { tasks.fetchAsPackage() } items = job.await() ?: emptyList() items.forEach { if (it.task.isDueToday() || !it.task.hasDueDate()) itemList.add(it) } } } override fun onDataSetChanged() = fetch() override fun getLoadingView(): RemoteViews = RemoteViews(context.packageName, R.layout.layout_widget_progress) override fun getItemId(position: Int): Long = position.toLong() override fun hasStableIds(): Boolean = true override fun getViewAt(position: Int): RemoteViews { val task = itemList[position].task val subject = itemList[position].subject val attachments = itemList[position].attachments val itemIntent = Intent().apply { putExtra(MainActivity.EXTRA_TASK, task) putExtra(MainActivity.EXTRA_SUBJECT, subject) putExtra(MainActivity.EXTRA_ATTACHMENTS, attachments) } val views = RemoteViews(context.packageName, R.layout.layout_item_widget) with(views) { setTextViewText(R.id.titleView, task.name) if (task.hasDueDate()) setTextViewText(R.id.summaryView, task.formatDueDate(context)) else setViewVisibility(R.id.summaryView, View.GONE) setOnClickFillInIntent(R.id.rootView, itemIntent) if (subject != null) setInt(R.id.imageView, "setColorFilter", subject.tag.color) else setViewVisibility(R.id.imageView, View.GONE) } return views } override fun getCount(): Int = itemList.size override fun getViewTypeCount(): Int = 1 override fun onCreate() {} override fun onDestroy() {} } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/task/widget/TaskWidgetService.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task.widget import android.content.Intent import android.widget.RemoteViewsService class TaskWidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent?): RemoteViewsFactory { return TaskWidgetRemoteViewFactory(applicationContext) } } ================================================ FILE: app/src/main/java/com/isaiahvonrundstedt/fokus/features/viewer/ImageViewer.kt ================================================ package com.isaiahvonrundstedt.fokus.features.viewer import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import coil.load import com.isaiahvonrundstedt.fokus.databinding.LayoutViewerImageBinding import com.isaiahvonrundstedt.fokus.features.shared.abstracts.BaseViewerFragment import java.io.File class ImageViewer(manager: FragmentManager) : BaseViewerFragment(manager) { private var _binding: LayoutViewerImageBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = LayoutViewerImageBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(binding.appBarLayout.toolbar) { setNavigationOnClickListener { dismiss() } title = arguments?.getString(EXTRA_TITLE) ?: arguments?.getString(EXTRA_IMAGE_PATH) } arguments?.getString(EXTRA_IMAGE_PATH)?.let { binding.imageContainer.load(File(it)) } } companion object { private const val EXTRA_TITLE = "extra:title" private const val EXTRA_IMAGE_PATH = "extra:path" fun show(manager: FragmentManager, path: String, title: String? = null) { ImageViewer(manager).apply { arguments = bundleOf( EXTRA_IMAGE_PATH to path, EXTRA_TITLE to title ) }.show() } } } ================================================ FILE: app/src/main/res/anim/anim_fade_in.xml ================================================ ================================================ FILE: app/src/main/res/anim/anim_slide_down.xml ================================================ ================================================ FILE: app/src/main/res/anim/anim_slide_up.xml ================================================ ================================================ FILE: app/src/main/res/color/color_text_input_stroke.xml ================================================ ================================================ FILE: app/src/main/res/color/selector_chip_background.xml ================================================ ================================================ FILE: app/src/main/res/color/selector_chip_stroke_color.xml ================================================ ================================================ FILE: app/src/main/res/color/selector_chip_text_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hero_sort_ascending_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hero_sort_descending_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_access_time_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_archive_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_arrow_back_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_attach_file_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_balance_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_calendar_month_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_celebration_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_check_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_checklist_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_close_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_code_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_color_lens_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_confirmation_number_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_date_range_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_delete_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_edit_note_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_event_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_event_busy_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_event_repeat_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_file_download_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_file_open_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_file_upload_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_filter_alt_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_info_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_lightbulb_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_link_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_location_on_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_menu_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_more_vert_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_music_note_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_notes_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_notifications_active_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_numbers_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_open_in_new_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_person_2_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_priority_high_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_query_stats_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_save_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_science_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_sensor_door_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_settings_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_share_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_translate_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_verified_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_wb_sunny_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_checkbox.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_bottom_sheet.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_calendar_current_day.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_calendar_selected_day.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_cascade_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_color_holder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_color_holder_chip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_color_holder_vertical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_icon_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shape_widget_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_icon_base_event.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_icon_base_subject.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_icon_base_task.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_icon_event.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_icon_subject.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_icon_task.xml ================================================ ================================================ FILE: app/src/main/res/drawable/toggle_checked_to_unchecked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/toggle_unchecked_to_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/vector_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/vector_unchecked.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_attach_to_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_container_event.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_container_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_container_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_about.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_archived_event.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_archived_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_archived_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_backup.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_editor_event.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_editor_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_editor_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_event.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_libraries.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_logs.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_notices.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_picker_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_root.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_appbar.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_appbar_editor.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_appbar_viewer.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_calendar_day.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_calendar_week_days.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_dialog_input_attachment.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_add.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_archived_event.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_archived_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_archived_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_event.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_library.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_log.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_menu.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_schedule.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_subject.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_subject_picker.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_subject_single.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_task.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_task_send.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_item_widget.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_navigation_header.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_preference_info.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_sheet_options.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_sheet_schedule.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_sheet_schedule_editor.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_viewer_image.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_widget_events.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_widget_progress.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_widget_subjects.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_widget_tasks.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_add.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_attachment.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_editor.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_events.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_logs.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_share.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_subjects.xml ================================================ ================================================ FILE: app/src/main/res/menu/menu_tasks.xml ================================================ ================================================ FILE: app/src/main/res/menu/navigation_main.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/navigation/navigation_container_event.xml ================================================ ================================================ FILE: app/src/main/res/navigation/navigation_container_subject.xml ================================================ ================================================ FILE: app/src/main/res/navigation/navigation_container_task.xml ================================================ ================================================ FILE: app/src/main/res/navigation/navigation_main.xml ================================================ ================================================ FILE: app/src/main/res/navigation/navigation_root.xml ================================================ ================================================ FILE: app/src/main/res/values/array.xml ================================================ oneplus samsung huawei meizu xiaomi wiko asus lenovo oppo nokia sony google htc @string/settings_theme_item_system @string/settings_theme_item_dark @string/settings_theme_item_light SYSTEM DARK LIGHT @string/settings_reminder_frequency_item_everyday @string/settings_reminder_frequency_item_weekends EVERYDAY WEEKENDS @string/settings_task_reminders_item_hour @string/settings_task_reminders_item_three_hours @string/settings_task_reminders_item_day 1 3 24 @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour @string/settings_event_reminders_item_hour 15 30 60 @string/settings_class_reminders_item_5_minutes @string/settings_class_reminders_item_15_minutes @string/settings_class_reminders_item_30_minutes 5 15 30 ================================================ FILE: app/src/main/res/values/attrs.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #006591 #ffffff #c9e6ff #001e2f #1160a4 #ffffff #d2e4ff #001c38 #494bd6 #ffffff #e1e0ff #07006c #ba1a1a #ffffff #ffdad6 #410002 #fcfcff #191c1e #fcfcff #191c1e #dde3ea #41474d #71787e #f0f0f3 #2e3133 #89ceff #000000 #006591 #006591 @color/theme_on_surface #808080 #392197fe #e3f2ff #bcdeff #2197fe #2076dc #1a46aa ================================================ FILE: app/src/main/res/values/dimen.xml ================================================ 32dp 24dp 16dp 8dp 16dp 32dp 32dp 0dp 24dp 256dp 12dp 12dp 56dp 16dp 8dp 4dp 8dp 16dp 32dp 40dp 40dp @dimen/item_padding 16sp 12sp 16sp 8dp 8dp 8dp 20dp 1dp 20dp 1dp 48dp 48dp 24dp ================================================ FILE: app/src/main/res/values/integer.xml ================================================ 300 400 ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Fokus @string/button_add_task @string/button_add_event @string/button_add_subject Your Tasks Your Pending Tasks Your Finished Tasks Your Subjects Your Subjects Today Your Subjects Tomorrow Your Archived Items Notification Logs Settings Backup and Restore Third Party Notices Open Source Licenses About @string/button_add More Sort Filter Share Import Clear Logs Export to file Share directly Archived Tasks Events Subjects Home Logs @string/activity_settings @string/activity_about Add Task Add Event Add Subject Add New Save Done Undo Remove Continue Mark as Finished Mark as Important Delete Cancel Discard Dismiss Learn More View Schedules No Added Subjects Subjects help sort your tasks and events, If you have one, add it using the button below. No Classes for Today You have no class scheduled for today. No Classes for Tomorrow You have no class schedules for tomorrow. No Pending Tasks You currently have no pending tasks. If you have one, add it using the button below. No Finished Tasks You currently have no finished tasks. Every task you marked as finished will show here. No Recorded Logs Once a task, event or any reminder appear at your notification shade, it will also appear here. No Scheduled Events You currently have no scheduled events. If you have one, add it using the button below. No Previous Events You currently have no previous events. Every event that is past its schedule will appear here No Archived Tasks No Archived Events No Archived Subjects Sunday Monday Tuesday Wednesday Thursday Friday Saturday Sun Mon Tue Wed Thu Fri Sat Week 1 Week 2 Week 3 Week 4 Assign Subject Pick a Start Time Pick an End Time Pick a color tag Select notification interval Choose theme Choose a backup file Do you want to remove \"%1$s\"? Once this item has been deleted, it cannot be recovered. Remove from archives? You can always swipe left to archive this item again. Import attachment? Due to the recent restrictions imposed by the Android system, the app will have to copy the file onto its own data folder. Continue? Never ask again Schedule Details Discard changes? Select file to import Send to Sharing options Class Schedules Filter Options New Attachment @string/field_attachment_name Enter Website URL Sorting Storage Permission needed To attach a file from your device, the application needs the storage permission to be granted. Import file Website URL Ascending Descending All Pending only Finished only Today only Tomorrow only Subject Code Description Days of Week Weeks of Month Start Time End Time Task Name Notes Subject Due Date Event Name Schedule Location Attachments Class Time Color Tag Priority Status Not Set In the next meeting Pick from class schedule Custom Attachment name Instructor Classroom The name of the subject Some ideas or minor details about the task Enter a room, a building or any place Some ideas or minor details about the event Subject removed Log removed Task removed Schedule removed Attachment added Attachment removed Event removed Task marked as finished Task marked as pending You forgot to enter the subject code You forgot to enter the description You forgot to add a schedule You forgot to specify the days You forgot to enter the start time You forgot to enter the end time You forgot to enter the task name You forgot to specify its due date You forgot to enter the event name You forgot to enter the location You forgot to enter the schedule Backup file is invalid Backup file is corrupted or unreadable There are no items to backup Backup process encountered an error Import in progress Import completed Import failed Export in progress Export completed Export failed Unable to share There are no valid information that can be shared A subject with this code already exists. A schedule conflicts with another schedule. A task with this name already exists. An event with this name already exists. An event with the same schedule already exists. Interface Sound Notifications Advanced Debugging Contribute Theme System default Dark Light Confetti Show confetti when tasks are marked as finished Completion Sounds Play a sound when you mark a task as completed Reminder frequency Everyday Weekends Remind me at this time When a task is nearing due Show notifications for tasks that are nearing its deadline Task reminder interval 1 hour before 3 hours before 1 day before Incoming events Show notifications about incoming events this day Event reminder interval 15 minutes before 30 minutes before @string/settings_task_reminders_item_hour Classes for this day Show notifications about classes for my subjects this day Class reminder interval 5 minutes before @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour More Settings Enable Week Numbers Show week numbers when configuring schedules. @string/activity_backup Backup No Previous Backups Restore Open links in external browser Use the system default browser to handle hyperlinks @string/button_learn_more Sometimes, your notification might not trigger due to the aggressive battery optimization by your phone manufacturer. Translate Report an issue @string/activity_notices Build Version Sky Grass Sunset Lemon Sea Grape Cherry Coral Midnight Mint Lavender Graphite Task Widget Tasks You have no tasks for today Event Widget Events You have no events for today Class Widget Classes You have no classes for today Marking this as important means that it will show a persistent notification at your notification shade. Reminders General Task Reminders Event Reminders Class Reminders You have %1$d tasks pending It might be worth to take a look Performing Backup Backup Failed Backup Completed Restoring Backup Restore Failed Restore Completed Attach to Task Good day! Good morning! Good afternoon! Good evening!  and  Yesterday at %1$s Yesterday Tomorrow at %1$s Tomorrow Today at %1$s Today Due today at %1$s Due tomorrow at %1$s Due at %1$s Open Source Libraries Other Resources Zapsplat Notification Sound Freepik Launcher Icon Tailwind Labs User Interface Icons Notifications are now enabled for this app! Notifications are NOT enabled for this app! ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values/values.xml ================================================ #000051 ================================================ FILE: app/src/main/res/values-ar/strings.xml ================================================ Fokus @string/button_add_task @string/button_add_event @string/button_add_subject مهامك المهام المؤجلة المهام المنتهية الموضوع مواضيعك لهذا اليوم مواضيعك ليوم غد الأشياء المؤرشفة سجل الأشعارات الأعدادات النسخ الأحتياطي والأستعادة أشعرات الطرف الثالث تراخيص مفتوحة المصدر عن @string/button_add المزيد فرز فلتره مشاركة أستيراد تنظيف السجل تصدير الى ملف مشاركة مباشرة مؤرشف المهام الأحداث المواضيع منزل السجلات @string/activity_settings @string/activity_about أضافة مهام أضافة حدث أضافة موضوع أضف جديد حفظ تم تراجع مسح أستمرار علم كمنتهية علم كمهمة مسح ألغاء تجاهل صرف النظر أعرف أكثر عرض جدول االمواضيع لاتوجد مواضيع مضافة تساعد المواضيع في فرز المهام والأحداث الخاصة بك ، إذا كان لديك واحد ، قم بإضافته باستخدام الزر أدناه. لاتوجد حصص لهذا اليوم ليس لديك فصل دراسي لهذا اليوم ليس لديك فصل دراسي ليوم غد ليس لديك مواعيد حصص ليوم غد لاتوجد مهام معلقة ليس لديك حاليا أي مهام معلقة.إذا كان لديك واحد ، قم بإضافته باستخدام الزر أدناه لاتوجد مهام منتهية ليس لديك حاليا أي مهام منتهية. ستظهر هنا كل مهمة حددتها على أنها منتهية. لايوجد قيد للسجلات بمجرد ظهور مهمة أو حدث أو أي تذكير في مركز الإشعارات ، سيظهر هنا أيضًا لاتوجد احداث مجدولة ليس لديك حاليا أي أحداث مجدولة. إذا كان لديك واحد ، قم بإضافته باستخدام الزر أدناه لاتوجد أحداث سابقة ليس لديك حاليا أي أحداث سابقة. سيظهر هنا كل حدث تجاوز جدوله الزمني لا توجد مهام مؤرشفة لا توجد أحداث مؤرشفة لا توجد مواضيع مؤرشفة الأحد الأثنين الثلاثاء الأربعاء الخميس الجمعة السبت أحد أثن ثلا أرب خمي جمع سبت الأسبوع 1 الأسبوع 2 الأسبوع 3 الأسبوع 4 أختر الموضوع أختر وقت البدأ أختر وقت الأنتهاء أختر لون أختر فترة الأخطار أختر ثيم اختر ملف النسخ الاحتياطي هل تريد الأزالة \"%1$s\"? بمجرد حذف هذا العنصر ، لا يمكن استعادته حذف من الارشيف؟ يمكنك السحب الى اليسار لأرشفة هذا العنصر مرة اخرى استيراد المرفقات؟ نظرًا للقيود الأخيرة التي يفرضها نظام أندرويد سيتعين على التطبيق نسخ الملف إلى مجلد البيانات الخاص به. أستمرار؟ لا تسأل مرة أخرى تفاصيل الجدول تجاهل التغييرات؟ حدد ملفًا للاستيراد أرسال الى خيارات المشاركة جداول الحصص خيارات الفلترة مرفق جديد @string/field_attachment_name أدخل عنوان لموقع الويب ترتيب يحتاج الى اذن التخزين لإرفاق ملف من جهازك ، يحتاج التطبيق إلى منح إذن التخزين. استيراد ملف رابط الموقع تصاعدي تنازلي الكل المعلقة فقط المنتهية فقط اليوم فقط غدا فقط كود الموضوع وصف ايام الاسبوع أسابيع من الشهر وقت البدء وقت النهاية اسم المهمة ملاحظات موضوع تاريخ الاستحقاق اسم الحدث جدول موقعك المرفقات وقت الفصل علامة اللون أولولية الحالة غير مضبوط في الاجتماع القادم اختر من جدول الحصص مخصص أسم المرفق معلم قاعة الدراسة اسم الموضوع بعض الأفكار أو التفاصيل الصغيرة حول المهمة أدخل غرفة أو مبنى أو أي مكان بعض الأفكار أو التفاصيل البسيطة حول الحدث تمت إزالة الموضوع تمت إزالة السجل تمت إزالة المهمة تمت إزالة الجدول تمت إضافة المرفق تمت إزالة المرفق تمت إزالة الحدث تم وضع علامة على المهمة على أنها منتهية تم وضع علامة على المهمة على أنها معلقة لقد نسيت إدخال رمز الموضوع لقد نسيت إدخال الوصف لقد نسيت إضافة جدول لقد نسيت تحديد الأيام لقد نسيت إدخال وقت البدء لقد نسيت إدخال وقت الانتهاء لقد نسيت إدخال اسم المهمة لقد نسيت تحديد تاريخ استحقاقه لقد نسيت إدخال اسم الحدث لقد نسيت أدخال الموقع لقد نسيت إدخال الجدول ملف النسخ الاحتياطي غير صالح ملف النسخ الاحتياطي تالف أو غير قابل للقراءة لا توجد عناصر للنسخ الاحتياطي واجهت عملية النسخ الاحتياطي خطأ الاستيراد قيد التقدم اكتمل الاستيراد فشل الاستيراد التصدير قيد التقدم اكتمل التصدير فشل التصدير غير قادر على المشاركة لا توجد معلومات صالحة يمكن مشاركتها موضوع بهذا الرمز موجود بالفعل. جدول يتعارض مع جدول آخر. توجد بالفعل مهمة بهذا الاسم. يوجد بالفعل حدث بهذا الاسم. حدث بالفعل بنفس الجدول الزمني موجود بالفعل. واجهه المستخدم صوت إشعارات المتقدمة تصحيح مساهمة ثيم النظام الافتراضي داكن فاتح قصاصات ورق ملون إظهار القصاصات عند وضع علامة على المهام كمنتهية أصوات الإنجاز قم بتشغيل صوت عند وضع علامة على مهمة كمكتملة معدل التذكير كل يوم أيام العطل ذكرني في هذا الوقت عندما تكون المهمة على وشك الاستحقاق إظهار إعلامات للمهام التي اقتربت من موعدها النهائي الفاصل الزمني لتذكير المهام قبل ساعة قبل 3 ساعات قبل يوم واحد الأحداث القادمة إظهار الإخطارات حول الأحداث الواردة هذا اليوم الفاصل الزمني لتذكير الحدث قبل 15 دقيقة قبل 30 دقيقة @string/settings_task_reminders_item_hour فصول لهذا اليوم إظهار الإخطارات حول الفصول الدراسية لموضوعاتي هذا اليوم الفاصل الزمني لتذكير الفصل قبل 5 دقائق @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour المزيد من الإعدادات تمكين أرقام الأسبوع إظهار أرقام الأسبوع عند تكوين الجداول. @string/activity_backup نسخ أحتياطي لا توجد نسخ احتياطية سابقة استعادة افتح الروابط في متصفح خارجي استخدم متصفح النظام الافتراضي للتعامل مع الارتباطات التشعبية @string/button_learn_more في بعض الأحيان ، قد لا يتم تشغيل إخطارك بسبب التحسين القوي للبطارية من قبل الشركة المصنعة للهاتف. ترجمة بلغ عن خطأ @string/activity_notices نسخة الإصدار سماء عشب غروب ليمون بحر عنب كرز المرجان منتصف الليل نعناع لافندر الجرافيت وجت المهمة مهام ليس لديك مهام لهذا اليوم وجت الحدث الأحداث ليس لديك أحداث لهذا اليوم وجت الحصة الحصص ليس لديك دروس لهذا اليوم يعني وضع علامة على هذا باعتباره مهمًا بأنه سيعرض إشعارًا دائمًا في مركز الإشعارات. تذكير عام تذكير المهام تذكير الحدث تذكير الفصل لديك %1$d مهمة معلقة قد يكون من المفيد إلقاء نظرة أداء النسخ الاحتياطي فشل النسخ الاحتياطي اكتمل النسخ الاحتياطي استعادة النسخة الاحتياطية فشلت الاستعادة اكتملت الاستعادة أضف كمرفق يوم جيد صباح الخير طاب مسائك مساء الخير  and  %1$s البارحة في البارحة %1$s البارحة في غدا %1$s غدا في اليوم %1$s اليوم في %1$s موعد التسليم غدًا في موعد التسليم %1$s مكتبات مفتوحة المصدر مصادر أخرى Zapsplat صوت الإشعار Freepik Launcher Icon Tailwind Labs أيكونه واجهة المستخدم تم تمكين الإخطارات الآن لهذا التطبيق! لم يتم تمكين الإخطارات لهذا التطبيق! ================================================ FILE: app/src/main/res/values-de/strings.xml ================================================ @string/button_add_task @string/button_add_event @string/button_add_subject Deine Aufgaben Deine Anstehenden Aufgaben Deine Erledigten Aufgaben Deine Fächer Deine Fächer Heute Deine Fächer Morgen Deine Archivierten Einträge Benachrichtigungsprotokolle Einstellungen Sichern und Wiederherstellen Hinweise zu Dritten Open Source Lizenzen Über @string/button_add Mehr Sortieren Filtern Teilen Importieren Protokolle Löschen Als Datei Exportieren Direkt Teilen Archiviert Aufgaben Termine Fächer Home Protokolle @string/activity_settings @string/activity_about Aufgabe Hinzufügen Neuer Termin Fach Hinzufügen Hinzufügen Neu Speichern Fertig Rückgängig Entfernen Weiter Als Erledigt Markieren Als Wichtig Markieren Löschen Abbrechen Verwerfen Schließen Mehr Erfahren Keine Hinzugefügten Fächer Fächer helfen bei der Sortierung Ihrer Aufgaben und Termine. Über den Button unten können Sie welche hinzufügen. Kein Unterricht Heute Sie haben heute keinen Unterricht. Kein Unterricht Morgen Sie haben morgen keinen Unterricht. Keine Anstehenden Aufgaben Sie haben aktuell keine anstehenden Aufgaben. Mit dem Button unten können Sie welche hinzufügen. Keine Erledigten Aufgaben Sie haben aktuell keine erledigten Aufgaben. Jede Aufgabe, die Sie als erledigt markieren wird hier erscheinen. Keine Aufgezeichneten Protokolle Sobald eine Aufgabe, ein Termin oder eine Erinnerung in Ihrer Benachrichtigungsleiste auftaucht, wird sie auch hier angezeigt. Keine Geplanten Termine Sie haben aktuell keine geplanten Termine. Mit dem Button unten können Sie welche hinzufügen. Keine Vergangenen Termine Sie haben aktuell keine vergangenen Termine. Jeder Termin, der in der Vegangenheit liegt, wird hier erscheinen. Keine Archivierten Aufgaben Keine Archivierten Termine Keine Archivierten Fächer Sonntag Montag Dienstag Mittwoch Donnerstag Freitag Samstag Son Mon Die Mit Don Fre Sam Woche 1 Woche 2 Woche 3 Woche 4 Fach Zuordnen Wähle einen Startzeitpunkt Wähle einen Endzeitpunk Wähle ein Farbtag Benachrichtigungsintervall wählen Theme wählen Wähle eine Sicherungsdatei Wollen Sie \"%1$s\" löschen? Sobald dieses Element gelöscht wurde, kann es nicht wiederhergestellt werden. Aus dem Archiv entfernen? Sie können jederzeit nach links wischen, um dieses Element erneut zu archivieren. Anhang importieren? Aufgrund der neuen Einschränkungen des Android-Systems muss die App die Datei in einen eigenen Dateiordner kopieren. Weiter? Nie wieder fragen Zeitplan Details Änderungen verwerfen? Zu importierende Datei wählen Senden an Optionen zum Teilen Stundenpläne Filter Optionen Neuer Anhang @string/field_attachment_name Website-URL Eingeben Sortierung Speicher-Berechtigung erforderlich Um eine Datei von Ihrem Gerät anzuhängen muss die App die Speicher-Berechtigung erhalten. Datei importieren Website URL Aufsteigend Absteigend Alles Nur Anstehendes Nur Erledigtes Nur Heute Nur Morgen Fachabkürzung Beschreibung Wochentage Wochen des Monats Startzeit Endzeit Aufgabenname Notizen Fach Fälligkeitsdatum Terminname Zeitplan Ort Anhänge Unterrichtszeit Farbtag Priorität Status Nicht Festgelegt Im Nächsten Treffen Aus dem Stundenplan auswählen Benutzerdefiniert Name des Anhangs Ausbilder Klassenzimmer Ein paar Ideen oder Details zur der Aufgabe Geben Sie einen Raum, ein Gebäude oder einen Platz ein Ein paar Ideen oder Details zu dem Termin Fach entfernt Protokoll entfernt Aufgabe entfernt Anhang hinzugefügt Anhang entfernt Termin entfernt Aufgabe als erledigt markiert Aufgabe als anstehend markiert Sie haben vergessen, die Fachabkürzung einzugeben Sie haben vergessen, die Beschreibung einzugeben Sie haben vergessen, einen Zeitplan hinzuzufügen Sie haben vergessen, die Tage festzulegen Sie haben vergessen, den Startzeitpunkt einzugeben Sie haben vergessen, den Endzeitpunkt einzugeben Sie haben vergessen, den Aufgabennamen einzugeben Sie haben vergessen, das Fälligkeitsdatum festzulegen Sie haben vergessen, den Terminnamen einzugeben Sie haben vergessen, den Ort einzugeben Sie haben vergessen, den Zeitplan einzugeben Sicherungsdatei ist ungültig Sicherungsdatei ist beschädigt oder unlesbar Es gibt keine zu sichernden Daten Fehler beim Sicherungsprozess Import läuft Import abgeschlossen Import fehlgeschlagen Export läuft Export abgeschlossen Export fehlgeschlagen Teilen ist nicht möglich Es gibt keine gültige Information, die geteilt werden kann Ein Fach mit dieser Abkürzung existiert bereits. Ein Zeitplan kollidiert mit einem anderen Zeitplan. Eine Aufgabe mit diesem Namen existiert bereits. Ein Termin mit diesem Namen existiert bereits. Ein Termin mit dem gleichen Zeitplan existiert bereits. Benutzeroberfläche Töne Benachrichtigungen Fortgeschritten Debugging Beitragen Theme System-Standard Dunkel Hell Konfetti Konfetti anzeigen, wenn Aufgaben als erledigt markiert werden Fertigstellungs-Ton Einen Ton abspielen, wenn eine Aufgabe als erledigt markiert wird Häufigkeit der Erinnerung Täglich Erinnere mich zu dieser Zeit Wenn eine Aufgabe bald fällig ist Zeige Benachrichtigungen für Aufgaben, die kurz vor der Fälligkeit stehen Erinnerungszeitpunkt für Aufgaben 1 Stunde vorher 3 Stunden vorher 1 Tag vorher Kommende Termine Benachrichtigungen für anstehende Termine an diesem Tag anzeigen Erinnerungszeitpunkt für Termine 15 Minuten vorher 30 Minuten vorher @string/settings_task_reminders_item_hour Unterricht an diesem Tag Zeige Benachrichtigungen für den Unterricht meiner Fächer an diesem Tag Erinnerungszeitpunkt für Unterricht 5 Minuten vorher @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour Mehr Einstellungen Wochennummern aktivieren Zeige Wochennummern beim Festlegen von Zeitplänen. @string/activity_backup Sicherung Keine Bisherigen Sicherungen Wiederherstellen Links in externem Browser öffnen Nutze den Standardbrowser des Systems, um Hyperlinks zu öffnen @string/button_learn_more Übersetzen Problem melden @string/activity_notices Build Version Himmel Gras Sonnenuntergang Zitrone Meer Weintraube Kirsche Koralle Mitternacht Minze Lavendel Graphit Aufgaben-Widget Aufgaben Sie haben keine Aufgaben für heute Termin-Widget Termine Sie haben keine Termine für heute Unterrichts-Widget Unterrichtsstunden Sie haben keinen Unterricht für heute Wenn Sie dies als wichtig markieren, wird eine dauerhafte Benachrichtigung in der Benachrichtigungsleiste angezeigt. Erinnerungen Allgemein Aufgabenerinnerungen Terminerinnerungen Unterrichtserinnerungen Sie haben %1$d anstehende Aufgaben Es könnte sich lohnen, einen Blick darauf zu werfen Sicherung läuft Sicherung fehlgeschlagen Sicherung abgeschlossen Sicherung wiederherstellen Wiederherstellung fehlgeschlagen Wiederherstellung abgeschlossen An Aufgabe anhängen Guten Tag! Guten Morgen! Guten Tag! Guten Abend!  und  Gestern, %1$s Gestern Morgen, %1$s Morgen Heute, %1$s Heute Fällig heute, %1$s Fällig morgen, %1$s Fällig am %1$s Open-Source-Bibliotheken Andere Ressourcen Benachrichtigungs-Ton Launcher Icon Icons der Benutzeroberfläche Termine Anzeigen Der Name des Fachs Zeitplan entfernt Wochenenden Manchmal kann es vorkommen, dass Benachrichtigungen aufgrund von aggressiver Akku-Optimierung durch den Hersteller des Telefons nicht gesendet wird. ================================================ FILE: app/src/main/res/values-es/strings.xml ================================================ Fokus @string/button_add_task @string/button_add_event @string/button_add_subject Tus Tareas Tus tareas pendientes Tus tareas finalizadas Tus Asignaturas Tus asignaturas de hoy Tus asignaturas de mañana Sus artículos archivados Registro de notificaciones Configuracion Backup y Restauración Avisos de terceros Licencias Open Source Acerca de @string/button_add Más Ordenar Filtro Compartir Importar Eliminar registros Exportar a archivo Compartir directamente Archivado Tareas Eventos Asignaturas Inicio Registro @string/activity_settings @string/activity_about Añadir tarea Añadir Evento Añadir Asignatura Agregar Nuevo Guardar Listo Deshacer Remover Continuar Marcar como Terminado Marcar como Importante Eliminar Cancelar Descartar Descartar Más información Ver Programas Sin asignaturas añadidas Las asignaturas te ayudan a ordenar tus tareas y eventos, Si tienes una, añadela con el boton de abajo. No hay clases hoy No tienes clases programadas para hoy. No hay clases mañana No tiene clases programadas para mañana. No hay tareas pendientes Por el momento no tienes tareas pendientes. Si tienes una, añadela con el boton de abajo. Sin tareas finalizadas Por el momento no tienes tareas finalizadas. Cada tarea marcada como finalizada se mostrará aquí. No hay registros Una vez que una tarea, evento o cualquier recordatorio aparezca en tus notificaciones, también aparecerá aquí. No hay eventos programados Por el momento no tienes eventos programados. Si tienes uno, añadela con el boton de abajo. No hay eventos previos Por el momento no tienes eventos previos. Cada evento que haya pasado su fecha de programación, aparecerá aquí. No hay tareas archivadas No hay eventos archivados No hay temas archivados Domingo Lunes Martes Miercoles Jueves Viernes Sabado Dom Lun Mar Mie Jue Vie Sab Semana 1 Semana 2 Semana 3 Semana 4 Asignar Asignatura Escoge una Hora de inicio Escoge una Hora de finalización Escoge un color para la etiqueta Seleccionar intervalo de notificacion Seleccionar tema Seleccionar archivo de backup Quieres remover \"%1$s\"? Una vez que este item sea borrado, no se puede recuperar. ¿Sacar de los archivos? Siempre puedes deslizar el dedo hacia la izquierda para volver a archivar este elemento. ¿Importar el archivo adjunto? Debido a las recientes restricciones impuestas por el sistema Android, la aplicación tendrá que copiar el archivo en su propia carpeta de datos. ¿Continuar? No vuelvas a preguntar Detalles del programa Descartar cambios? Seleccionar archivo para importar Enviar a Opciones para compartir Programas de clase Opciones de filtro Nuevo Adjunto @string/field_attachment_name Ingresa la URL del sitio web Ordenar Permiso de almacenamiento necesario Para adjuntar un archivo desde su dispositivo, la aplicación necesita que se conceda el permiso de almacenamiento. Importar archivo URL del sitio web Ascendente Descendente Todos Solo pendientes Solo finalizados Solo de hoy Solo de mañana Codigo de asignacion Descripcion Dias de la semana Semanas del mes Hora de inicio Hora de finalización Nombre de la tarea Notas Asignatura Fecha limite Nombre del evento Programa Lugar Archivos adjuntos Hora de clase Color de etiqueta Prioridad Estado No establecido En la proxima reunion Elegir del programa de clases Personalizado Nombre del archivo adjunto Instructor Aula El nombre de la asignatura Algunas ideas o detalles menores sobre la tarea Entrar a una habitación, edificio o cualquier lugar Algunas ideas o detalles menores sobre el evento Asignatura removida Registro removido Tarea removida Programa removido Adjunto añadido Adjunto removido Evento removido Tarea marcada como finalizada Tarea marcada como pendiente Olvidaste agregar el codigo de la asignacion Olvidaste agregar una descripcion Olvidaste agregar un programa Olvidaste especificar los dias Olvidaste agregar el horario de inicio Olvidaste agregar el horario de finalización Olvidaste agregar el nombre de la tarea Olvidaste especificar la fecha limite Olvidaste agregar el nombre del evento Olvidaste agregar el lugar Olvidaste agregar el programa El archivo de backup es invalido El archivo de backup esta corrupto No hay items para el backup El proceso de backup tuvo un error Importacion en progreso Importacion completada Importacion fallida Exportacion en progreso Exportacion completada Exportacion fallida No es posible compartir No hay inforamción valida para ser compartida Ya existe un asignacion con este código. Un horario entra en conflicto con otro horario. Ya existe una tarea con este nombre. Ya existe un evento con este nombre. Ya existe un evento con el mismo horario. Interface Sonido Notificaciones Avanzado Debugging Contribuir Tema Default del sistema Oscuro Claro Confetti Mostrar confetti cuando las tareas se marquen como finalizadas Sonidos de finalizacion Reproducir un sonido cuando las tareas se marquen como finalizadas Frecuencia de recordatorio Todos los dias Fines de semana Recordame a esta hora Cuando la tarea este cerca de la fecha limite Mostrar notificaciones para tareas que esten cerca de su fecha limite Intervalo de recordatorio para las tareas 1 hora antes 3 horas antes 1 dia antes Eventos entrantes Mostrar notificaciones acerca de eventos entrantes para este dia Intervalo de recordatorio para los eventos 15 minutos antes 30 minutos antes @string/settings_task_reminders_item_hour Clases para este dia Mostrar notificaciones de clases para este dia Intervalo de recordatorio para las clases 5 minutos antes @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour Mas opciones Activar los números de la semana Mostrar los números de semana al configurar los horarios. @string/activity_backup Backup No hay Backups previos Restaurar Abrir links en navegador externo Usar el navegador default del sistema para abrir hyperlinks @string/button_learn_more A veces no te llegarán notificaciones debido a la agresiva optimizacion de bateria del fabricante. Traducir Reportar un problema @string/activity_notices Build Version Cielo Cesped Ocaso Limon Mar Uva Cereza Coral Medianoche Menta Lavanda Grafito Widget de tareas Tareas No tienes tareas por hoy Widget de eventos Eventoss No tienes eventos por hoy Widget de clases Clases No tienes clases por hoy Marcar esto como importante significa que mostrará una notificacion permanente en tu barra de notificaciones. Recordatorios General Recordatorios de tareas Recordatorios de eventos Recordatorios de clases Tienes %1$d tareas pendientes Quizás quieras echarle un vistazo Realizando backup Backup Fallido Backup Completado Restaurando backup Restauracion fallida Restauracion completada Agregar como adjunto Buen dia! Buenos días! Buenas tardes! Buenas noches!  and  Ayer a las %1$s Ayer Mañana a las %1$s Mañana Hoy a las %1$s Hoy Fecha limite hoy a las %1$s Fecha limite hoy a las %1$s Con fecha de vencimiento en %1$s Open Source Libraries Otros recursos Zapsplat Sonido de notificacion Freepik Icono del launcher Tailwind Labs Iconos de la interfaz del usuario ================================================ FILE: app/src/main/res/values-fr/strings.xml ================================================ @string/button_add_task @string/button_add_event @string/button_add_subject Vos tâches Vos tâches en cours Vos tâches terminées Vos matières Vos matières d’aujourd’hui Vos matières de demain Vos archives Journaux de notification Paramètres Sauvegarde et restauration Mentions tierces Licences open source À propos @string/button_add Plus Trier Filtrer Partager Importer Effacer les journaux Exporter dans un fichier Partager directement Archivé Tâches Événements Matières Accueil Journaux @string/activity_settings @string/activity_about Ajouter une tâche Ajouter un événement Ajouter une matière Ajouter Nouveau Enregistrer Terminé Annuler Supprimer Continuer Marquer comme terminé Marquer comme important Supprimer Annuler Annuler Fermer En savoir plus Voir les horaires Aucune matière ajoutée Les matières vous aident à classer vos tâches et événements. Ajoutez-en un en utilisant le bouton ci-dessous. Pas de classes aujourd’hui Vous n’avez pas de classe planifiée aujourd\'hui. Pas de classes demain Vous n’avez pas de classe planifiée demain. Aucune tâche en cours Vous n’avez aucune tâche en cours. Si vous en avez une, ajoutez-la en utilisant le bouton ci-dessus. Aucune tâche terminée Vous n’avez actuellement aucune tâche terminée. Vous retrouverez toutes les tâches marquées comme terminées ici. Aucun journal enregistré Dès qu’une tâche, un événement ou un rappel est affiché dans votre volet de notifications, il apparaîtra également ici. Aucun événement planifié Vous n’avez actuellement aucun événement planifié. Vous pouvez en ajouter un en utilisant le bouton ci-dessous. Aucun événement antérieur Vous n’avez actuellement aucun événement antérieur. Vous retrouverez ici tous les éléments dont la date est dépassée. Aucune tâche archivée Aucun événement archivé Aucune matière archivée Dimanche Lundi Mardi Mercredi Jeudi Vendredi Samedi Dim Lun Mar Mer Jeu Ven Sam Semaine 1 Semaine 2 Semaine 3 Semaine 4 Attribuer une matière Choisir une heure de début Choisir une heure de fin Choisir une couleur pour l’indicateur Sélectionner un intervalle de notification Choisir un thème Choisir un fichier de sauvegarde Voulez-vous supprimer \"%1$s\" ? Une fois supprimé, il ne pourra plus être récupéré. Supprimer des archives ? Vous pouvez toujours glisser vers la gauche pour l\'archiver à nouveau. Importer la pièce jointe ? De nouvelles restrictions imposées par le système Android obligent l\'application à copier le fichier dans son propre répertoire de données. Continuer ? Ne plus demander Détails des horaires Annuler les modifications ? Sélectionner un fichier à importer Envoyer à Paramètres de partage Horaires des classes Paramètres de filtre Nouvelle pièce jointe @string/field_attachment_name Saisissez l’URL du site Web Tri Permission de stockage requise Pour joindre un fichier depuis votre appareil, vous devez accorder la permission de stockage à l\'application. Importer le fichier URL du site Web Croissant Décroissant Tous En cours uniquement Terminé uniquement m Aujourd\'hui uniquement Hier uniquement Code de la matière Description Jours de la semaine Semaines du mois Heure de début Heure de fin Nom de la tâche Notes Matière Date d\'échéance Nom de l’événement Planning Lieu Pièces jointes Horaire de la classe Couleur de l’indicateur Priorité Statut Non configuré Lors du prochain rendez-vous Choisir dans les horaires des classes Personnalisé Nom de la pièce jointe Instructeur Salle de classe Le nom de la matière Quelques idées ou détails mineurs concernant la dernière tâche Saisir une salle, un bâtiment ou un lieu Quelques idées ou détails mineurs concernant le dernier événement Matière supprimée Journal supprimé Tâche supprimée Horaire supprimé Pièce jointe ajoutée Pièce jointe supprimée Événement supprimé Tâche marquée comme terminée Tâche marquée comme en cours Vous avez oublié de saisir le code de la matière Vous avez oublié de saisir la description Vous avez oublié d’ajouter un horaire Vous n’avez pas précisé les jours Vous n’avez pas précisé l’heure de début Vous n’avez pas précisé l’heure de fin Vous avez oublié de saisir le nom de la tâche Vous avez oublié de saisir la date d’échéance Vous avez oublié de saisir le nom de l’événement Vous avez oublié de saisir le lieu Vous avez oublié de saisir l’horaire Le fichier de sauvegarde n’est pas valide. Le fichier de sauvegarde est corrompu ou ne peut pas être lu. Il n’y a rien à sauvegarder. Le processus de sauvegarde a rencontré une erreur. Importation en cours Importation terminée Échec de l’importation Exportation en cours Exportation terminée Échec de l’exportation Impossible de partager Aucune information valide ne peut être partagée Un événement avec le même horaire existe déjà. Un horaire entre en conflit avec un autre horaire. Une tâche portant ce nom existe déjà. Un événement portant ce nom existe déjà. Un événement avec le même horaire existe déjà. Interface Son Notifications Avancé Débogage Contribuer Thème Valeur par défaut du système Sombre Clair Confetti Afficher des confettis lorsque des tâches passent à l\'état terminé. Effets sonores de félicitations Jouer un son lorsqu’une tâche passe à l\'état terminé. Fréquence des rappels Quotidienne Weekend Rappelez-moi à ce moment Lorsqu’une tâche approche son échéance Afficher les notifications pour les tâches approchant leur échéance Intervalle de rappel pour les tâches 1 heure avant 3 heures avant 1 jour avant Événements à venir Afficher les notifications pour les événements du jour à venir Intervalle de rappel pour les événements 15 minutes avant 30 minutes avant @string/settings_task_reminders_item_hour Classes aujourd\'hui Afficher les notifications pour mes matières du jour Intervalle de rappel pour les classes 5 minutes avant @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour Plus de paramètres Activer les numéros de semaine Afficher les numéros de semaine lors de la configuration des horaires. @string/activity_backup Sauvegarde Pas de sauvegarde antérieure Restaurer Ouvrir les liens dans un navigateur externe Utiliser la valeur par défaut du système pour gérer les liens @string/button_learn_more Les notifications pourraient ne pas s’afficher à cause de l’optimisation de l’utilisation de la batterie du fabricant de votre téléphone. Traduire Signaler un problème @string/activity_notices Construire une version Ciel Gazon Coucher de soleil Citron Mer Raisin Cerise Corail Minuit Menthe Lavande Graphite Widget des tâches Tâches Vous n’avez aucune tâche aujourd\'hui Widget des événements Événements Vous n’avez aucun événement aujourd\'hui Widget des classes Classes Vous n’avez aucune classe aujourd\'hui Marquer ceci comme important permettra d’afficher une notification persistante dans votre volet de notifications.Rappels Général Rappels pour les tâches Rappels pour les événements Rappels pour les classes Vous avez %1$d tâches en cours Jetez-y donc un œil ! Sauvegarde en cours Échec de la sauvegarde Sauvegarde terminée Restauration de la sauvegarde Échec de la restauration Restauration terminée Ajouter comme pièce jointe Bonne journée ! Bonjour ! Bonjour ! Bonsoir ! et Hier à %1$s Hier Demain à %1$s Demain Aujourd’hui à %1$s Aujourd’hui Prévu aujourd’hui à %1$s Prévu demain à %1$s Due à %1$s Bibliothèques open source Autres ressources Zapsplat Son de notification Freepik Icône de lancement Tailwind Labs Icônes de l’interface utilisateur ================================================ FILE: app/src/main/res/values-hdpi/dimen.xml ================================================ 20sp 16sp 18sp 16dp 16dp ================================================ FILE: app/src/main/res/values-id/strings.xml ================================================ Fokus @string/button_add_task @string/button_add_event @string/button_add_subject Tugas Anda Tugas Tertunda Tugas Selesai Subjek Anda Subjek Anda Hari Ini Subjek Anda Besok Item Anda yang Diarsipkan Log Pemberitahuan Pengaturan Cadangkan dan Pulihkan Pemberitahuan Pihak Ketiga Lisensi Sumber Terbuka Tentang @string/button_add Lainnya Menyortir Saring Bagikan Impor Hapus Log Ekspor ke file Bagikan langsung Arsip Tugas Acara Subjek Beranda Log @string/activity_settings @string/activity_about Tambah tugas Tambah acara Tambah subjek Tambah Baru Simpan Selesai Undo Hapus Lanjut Tandai selesai Tandai penting Hapus Batal Batal Batal Pelajari lebih lanjut Lihat Jadwal Tidak Ada Subjek yang Ditambahkan Subjek membantu mengurutkan tugas dan acara Anda, Jika ada, tambahkan menggunakan tombol di bawah ini. Tidak Ada Kelas untuk Hari Ini Anda tidak memiliki kelas yang dijadwalkan untuk hari ini. Tidak Ada Kelas untuk Besok Anda tidak memiliki jadwal kelas untuk besok. Tidak Ada Tugas yang Menunggu Keputusan Saat ini Anda tidak memiliki tugas yang menunggu keputusan. Jika Anda punya, tambahkan menggunakan tombol di bawah. Tidak Ada Tugas yang Selesai Saat ini Anda tidak memiliki tugas yang sudah selesai. Setiap tugas yang Anda tandai sebagai selesai akan ditampilkan di sini. Tidak Ada Log Terekam Setelah tugas, acara, atau pengingat apa pun muncul di bayangan pemberitahuan Anda, itu juga akan muncul di sini. Tidak Ada Acara Terjadwal Saat ini Anda tidak memiliki acara terjadwal. Jika Anda punya, tambahkan menggunakan tombol di bawah. Tidak Ada Acara Sebelumnya Saat ini Anda tidak memiliki acara sebelumnya. Setiap acara yang melewati jadwalnya akan muncul di sini Tidak Ada Tugas yang Diarsipkan Tidak Ada Acara yang Diarsipkan Tidak Ada Subjek yang Diarsipkan Minggu Senin Selasa Rabu Jumat Sabtu Min Sen Sel Rab Kam Jum Sab Minggu 1 Minggu 2 Minggu 3 Minggu 4 Tetapkan Subjek Pilih Waktu Mulai Pilih Waktu Berakhir Pilih label warna Pilih interval pemberitahuan Pilih tema Pilih file cadangan Apakah Anda ingin menghapus \"%1$s\"? Setelah item ini dihapus, item tersebut tidak dapat dipulihkan. Hapus dari arsip? Anda selalu dapat menggeser ke kiri untuk mengarsipkan item ini lagi. Impor lampiran? Aplikasi harus menyalin file ke folder datanya sendiri karena batasan terbaru yang diberlakukan oleh sistem Android. Lanjutkan? Jangan tanya lagi Detail Jadwal Membuang perubahan? Pilih file untuk diimpor Kirim ke Opsi berbagi Jadwal Kelas Opsi Filter Lampiran Baru @string/field_attachment_name Masukkan URL Situs Web Penyortiran Izin Penyimpanan diperlukan Untuk melampirkan file dari perangkat Anda, aplikasi memerlukan izin penyimpanan yang akan diberikan. Impor file URL Situs Web Menaik Menurun Semua Tertunda saja Selesai saja Hari ini saja Besok saja Kode Subjek Deskripsi Hari dalam seminggu Minggu dalam sebulan Waktu Mulai Waktu Akhir Nama Tugas Catatan Subjek Tenggat Waktu Nama Acara Jadwal Lokasi Lampiran Waktu Kelas Label Warna Prioritas Status Tidak diatur Dalam pertemuan berikutnya Pilih dari jadwal kelas Kustom Nama lampiran Instruktur Ruang Kelas Nama subjek Beberapa ide atau detail kecil tentang tugas Masuki ruangan, gedung, atau tempat mana pun Beberapa ide atau detail kecil tentang acara tersebut Subjek dihapus Log dihapus Tugas dihapus Jadwal dihapus Lampiran ditambahkan Lampiran dihapus Acara dihapus Tugas ditandai sebagai selesai Tugas ditandai sebagai tertunda Anda lupa memasukkan kode subjek Anda lupa memasukkan deskripsi Anda lupa menambahkan jadwal Anda lupa menentukan hari Anda lupa memasukkan waktu mulai Anda lupa memasukkan waktu akhir Anda lupa memasukkan nama tugas Anda lupa menentukan tanggal jatuh tempo Anda lupa memasukkan nama acara Anda lupa memasukkan lokasi Anda lupa memasukkan jadwal File cadangan tidak valid File cadangan rusak atau tidak dapat dibaca Tidak ada item untuk dicadangkan Proses pencadangan mengalami kesalahan Impor sedang berlangsung Impor selesai Impor gagal Ekspor sedang berlangsung Ekspor berhasil Ekspor gagal Tidak dapat membagikan Tidak ada informasi valid yang dapat dibagikan Antarmuka Suara Notifikasi Lanjutan Debugging Kontribusi Tema Default sistem Gelap Terang Konfeti Tunjukkan konfeti ketika tugas ditandai sebagai selesai Suara Penyelesaian Putar suara saat Anda menandai tugas sebagai selesai Frekuensi pengingat Setiap hari Akhir pekan Ingatkan saya saat ini Saat tugas hampir jatuh tempo Tampilkan pemberitahuan untuk tugas yang mendekati tenggat waktunya Interval pengingat tugas Sejam sebelumnya 3 jam sebelumnya 1 hari sebelumnya Acara yang akan datang Tampilkan pemberitahuan tentang acara masuk hari ini Interval pengingat acara 15 menit sebelumnya 30 menit sebelumnya @string/settings_task_reminders_item_hour Kelas untuk hari ini Tampilkan pemberitahuan tentang kelas untuk subjek saya hari ini Interval pengingat kelas 5 menit sebelumnya @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour Pengaturan Lainnya @string/activity_backup Cadangan Tidak ada cadangan Pulihkan Buka tautan di browser eksternal Gunakan browser default sistem untuk menangani hyperlink @string/button_learn_more Terkadang, pemberitahuan Anda mungkin tidak terpicu karena pengoptimalan baterai yang agresif oleh pabrikan ponsel Anda. Terjemahkan Laporkan masalah @string/activity_notices Versi Sky Grass Sunset Lemon Sea Grape Cherry Coral Midnight Mint Lavender Grafit Widget Tugas Tugas Anda tidak ada tugas hari ini Widget Acara Acara Anda tidak ada acara hari ini Widget Kelas Kelas Anda tidak ada kelas hari ini Menandai ini sebagai penting berarti ini akan menampilkan pemberitahuan terus-menerus di bayangan pemberitahuan Anda. Pengingat Umum Pengingat Tugas Pengingat Acara Pengingat Kelas Anda punya %1$d tugas yang tertunda Mungkin layak untuk dilihat Melakukan pencadangan Pencadangan gagal Pencadangan selesai Memulihkan cadangan Pemulihan gagal Pemulihan selesai Lampirkan pada tugas Semoga harimu menyenangkan! Selamat pagi! Selamat siang! Selamat sore!  dan  Kemarin pukul %1$s Kemarin Besok pukul %1$s Besok Hari ini pukul %1$s Hari ini " Tenggat hari ini pukul %1$s" " Tenggat besok pukul %1$s" Jatuh tempo pada %1$s Open Source Libraries Bahan Lainnya Zapsplat Suara Notifikasi Freepik Ikon Launcher Tailwind Labs Ikon Antarmuka Pengguna Aktifkan Nomor Minggu Tampilkan nomor minggu saat mengonfigurasi jadwal. Acara dengan jadwal yang sama sudah ada. Acara dengan nama ini sudah ada. Tugas dengan nama ini sudah ada. Jadwal bentrok dengan jadwal lain. Subjek dengan kode ini sudah ada. Kamis ================================================ FILE: app/src/main/res/values-night/colors.xml ================================================ #89ceff #89ceff #004c6e #c9e6ff #a1c9ff #a1c9ff #004880 #d2e4ff #c0c1ff #1000a9 #2f2ebe #e1e0ff #ffb4ab #690005 #93000a #ffdad6 #191c1e #e2e2e5 #191c1e #e2e2e5 #41474d #c1c7ce #8b9198 #191c1e #e2e2e5 #006591 #000000 #89ceff #89ceff @color/theme_on_surface #808080 ================================================ FILE: app/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-night-v23/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-night-v27/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-ru/strings.xml ================================================ @string/button_add_task @string/button_add_event @string/button_add_subject Твои Задачи Твои Отложенные Задачи Ваши Готовые Задачи Ваши субъекты Ваши темы сегодня Ваши темы Завтра Ваши заархивированные предметы Журналы уведомлений Настройки Резервное копирование и восстановление Уведомления третьих лиц Лицензии с открытым исходным кодом о приложении @string/button_add Подробнее Сортировать Фильтр Поделиться Импорт Чистые журналы Экспорт в файл Поделиться напрямую Архивный Задачи События Предметы Главная страница Журналы @string/activity_settings @string/activity_about Добавить задачу Добавить Событие Добавить Предмет Добавить Новый сайт Сохранить Готово Отменить Удалить Продолжать Отметить как законченный Отметить как важный Удалить Отменить Бросить Свободен Узнать больше Посмотреть Графики Не добавлены Субъекты Предметы помогают сортировать ваши задачи и события, Если у вас есть, добавьте его, используя кнопку ниже. Классов на сегодня нет На сегодня у тебя занятия не запланированы. Занятия завтра не будут У тебя нет расписания занятий на завтра. Нет Отложенных Задач В настоящее время у вас нет незавершённых дел. Если оно у вас есть, добавьте его с помощью кнопки ниже. Нет готовых заданий В настоящее время у вас нет законченных заданий. Каждая задача, которую вы отметили как Завершенную, будет показана здесь. Нет записанных журналов Как только задание, событие или любое напоминание появится в тени уведомления, оно также появится здесь. Нет Запланированных событий В настоящее время у вас нет запланированных мероприятий. Если оно у вас есть, добавьте его с помощью кнопки ниже. Предыдущие события отсутствуют В настоящее время у вас нет никаких предыдущих событий. Каждое событие, прошедшее по расписанию, будет отображаться здесь. Архивных задач нет Нет архивных событий Нет архивных материалов воскресенье Понедельник вторник среда четверг пятница суббота Вск Пнд Втр Срд Чтв Птн Сбт 1 неделя 2 неделя 3 неделя 4 неделя Назначить Предмет Выберите время начала Выберите время окончания Выберите цветной тег Выберите интервал уведомления Выберите тему Выберите резервный файл Вы хотите удалить \ "%1$s\"? После удаления этот пункт не может быть восстановлен. Удалить из архивов? Вы всегда можете пролистать влево, чтобы заново архивировать этот пункт. Импортное крепление? В связи с недавними ограничениями, наложенными системой Android, приложение должно будет скопировать файл в свою собственную папку данных. Продолжить? Никогда больше не спрашивайте. Подробная информация о расписании Отбрасывать изменения? Выберите файл для импорта Отправить Совместное использование опционов Графики занятий Опции фильтра Новое приложение @string/field_attachment_name Введите URL-адрес сайта Сортировка Разрешение на хранение необходимо Чтобы прикрепить файл с устройства, приложению необходимо получить разрешение на хранение. Файл импорта URL-адрес веб-сайта По возрастанию По убыванию Все Только в ожидании Выполненные Сегодня только Только завтра Предметный код Описание Дни недели Недели месяца Время начала Время окончания Имя задачи Примечания Предмет Срок исполнения Название события Расписание Местоположение Приложения Классное время Цветная метка Приоритет Статус Не установлен Следующая встреча Выберите из расписания занятий Пользовательский Имя приложения Инструктор Классная комната Имя субъекта Некоторые идеи или мелкие детали задачи Войти в комнату, здание или любое другое место. Некоторые идеи или мелкие детали мероприятия Предмет удален Журнал удален Задача снята Расписание удалено Крепление удалено Добавлено приложение Событие удалено Задача обозначена как Готово Задача помечена как Ожидающая решения Вы забыли ввести код Предмет Вы забыли ввести описание Ты забыл добавить расписание Вы забыли указать дни Ты забыл ввести время начала Ты забыл ввести время окончания Вы забыли ввести имя задачи Вы забыли указать срок его исполнения Вы забыли ввести название мероприятия Вы забыли указать место Вы забыли войти в расписание Резервный файл недействителен Резервный файл поврежден или нечитаем. Нет пунктов для резервного копирования В процессе резервного копирования произошла ошибка Импорт в процессе осуществления Завершённый импорт Импорт не состоялся Экспорт продолжается Экспорт завершен Экспорт не состоялся Невозможно поделиться Нет никакой достоверной информации, которой можно было бы поделиться Объект с таким кодом уже существует. Расписание конфликтует с другим расписанием. Задание с таким именем уже существует. Событие с таким именем уже существует. Мероприятие с таким же расписанием уже существует. Интерфейс Звуковой Уведомления Расширенный сайт Отладка Делать вклад Тема Стандартная система Темный Свет Конфетти Показывать конфетти, когда задачи помечены как завершенные Звуки завершения Воспроизвести звук, когда вы отмечаете задачу как выполненную Частота напоминаний Ежедневно Выходные Напомни мне сейчас Когда задача близка к выполнению. Показывать уведомления о задачах, которые приближаются к своему сроку выполнения Интервал напоминания о задаче за 1 час до за 3 часа до за 1 день до Всплывающие события Показывать уведомления о входящих событиях в этот день Интервал напоминаний о событиях за 15 минут до за 30 минут до @string/settings_task_reminders_item_hour Занятия по сей день Показывать уведомления об уроках по моим предметам в этот день Интервал напоминания класса за 5 минут до @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour Дополнительные настройки Включить номера недель Показывать номера недель при настройке расписаний. @string/activity_backup Резервное копирование Нет предыдущих резервных копий Восстановление Открытые ссылки во внешнем браузере Используйте системный браузер по умолчанию для работы с гиперссылками. @string/button_learn_more Иногда ваше уведомление может не сработать из-за агрессивной оптимизации батареи производителем вашего телефона. Перевести Сообщить о проблеме @string/activity_notices Версия для строительства Sky Трава Закат Lemon Море Виноград Вишня Коралл Midnight Мятный двор Лаванда Графит Виджет задач Задачи У вас нет задач на сегодня. Ивент-виджет События У вас нет никаких событий на сегодня. Виджет класса Занятия У тебя сегодня нет занятий. Пометка "важно" означает, что он будет показывать постоянное уведомление в тени уведомления. Напоминания Общие сведения Напоминания о задачах Напоминания о событиях Напоминатели класса У вас есть задачи %1$d Возможно, стоит взглянуть Выполнение резервного копирования Резервное копирование не удалось Резервное копирование Завершено Восстановление резервного копирования Восстановить Не удалось Восстановлено Завершено Добавить как Приложение Добрый день! Доброе утро! Добрый день! Добрый вечер!  и  Вчера на %1$s Вчера Завтра на %1$s Завтра Сегодня на %1$s Сегодня Срок исполнения сегодня %1$s Срок исполнения завтрашнего дня %1$s В срок %1$s Библиотеки с открытым исходным кодом Прочие ресурсы Звук уведомления икона-лаунчер Иконки пользовательского интерфейса ================================================ FILE: app/src/main/res/values-tr/strings.xml ================================================ Fokus @string/button_add_task @string/button_add_event @string/button_add_subject Görevler Bekleyen Görevler Tamamlanmış Görevler Konular Bugünkü Konular Yarınki Konular Arşivlenmiş Kayıtlar Bildirim Günlüğü Ayarlar Yedekle ve Geri Yükle Third Party Notices Açık Kaynak Lisansları Hakkında @string/button_add Daha Çok Sırala Filtrele Paylaş İçe Aktar Günlüğü Temizle Dosya olarak dışa aktar Direkt paylaş Arşivlenmiş Görevler Etkinlikler Konular Ana Sayfa Günlük @string/activity_settings @string/activity_about Görev Oluştur Etkinlik Oluştur Konu Oluştur Oluştur Yeni Kaydet Tamamla Geri Al Sil Devam Et Tamamlandı Olarak İşaretle Önemli Olarak İşaretle Sil İptal Vazgeç Reddet Daha Fazlası Hatırlatıcıları Görüntüle Hiç hatırlatıcı yok Konular, görevlerinizi ve etkinliklerinizi filtrelemenize yardımcı olur. Daha önce bir konu oluşturmadıysanız aşağıdaki butonu kullanarak ekleyebilirsiniz. Bugün Ders Yok Bugün için ayarlanmış herhangi bir ders bulunmuyor. Yarın Ders Yok Yarın için ayarlanmış herhangi bir ders bulunmuyor. Bekleyen Görev Yok Bekleyen hiçbir göreviniz bulunmuyor. Yeni bir görev eklemek için aşağıdaki butonu kullanabilirsiniz. Tamamlanmış Görev Yok Tamamlanmış hiçbir göreviniz bulunmuyor. Tamamlandı olarak işaretlediğiniz görevler burada görünür. Kayıt Günlüğü Boş Herhangi bir görev,olay veya hatırlatıcı bildirim çubuğunuzda göründüğünde; burada da görünüyor olacak. Planlanmış Etkinlik Yok Planlanmış hiçbir etkinlik bulunmuyor. Yeni bir etkinlik eklemek için aşağıdaki butonu kullanabilirsiniz. Geçmiş Etkinlik Yok Tarihi geçmiş hiçbir etkinlik bulunmuyor. Tarihi geçmi etkinlikler burada görünür. Arşivlenmiş Görev Yok Arşivlenmiş Etkinlik Yok Arşivlenmiş Konu Yok Pazar Pazartesi Salı Çarşamba Perşembe Cuma Cumartesi Pzr Pzt Sal Çrş Prş Cum Cts 1. Hafta 2. Hafta 3. Hafta 4. Hafta Konu Ata Başlangıç Tarihi Seç Bitiş Tarihi Seç Etiket rengi seç Bildirim aralığını seç Temayı seç Yedekleme dosyası seç \"%1$s\" silinecek. Bu işlem geri alınamaz. Devam etmek istiyor musunuz? Arşivden silinsin mi? Sola doğru kaydırarak bu kaydı arşivleyebilirsiniz. Ek içe aktarılsın mı? Android işletim sisteminin getirdiği yeni kısıtlamalardan dolayı, uygulama ek dosyasını kendi alanına kopyalayacak. Devam edilsin mi? Bir daha sorma Hatırlatıcı Detayları Değişiklerden vazgeçilsin mi? İçe aktarılacak dosyayı seçin Şuna gönder Paylaşım Ayarları Ders Hatırlatıcıları Filtreleme Ayarları Yeni Ek Oluştur @string/field_attachment_name Web site adresini girin Sıralama Kayıt Yeri İzni Gerekiyor Cihazınızdan bir dosyayı içe aktarabilmek için kayıt yeri iznine ihtiyacımız var. Dosyayı İçeri Aktar Web Adresi Artan Azalan Tümü Bekleyen Tamamlanmış Bugün Yarın Konu Açıklama Haftanın Günleri Ayın Haftaları Başlangıç Tarihi Bitiş Tarihi Görev Adı Notlar Konu Bitiş Tarihi Etkinlik Adı Hatılatıcı Konum Ekler Ders Saati Renk Etiketi Öncelik Durum Ayarlanmamış Bir sonraki toplantıda Ders Hatırlatıcılarından seç Özel Ek adı Eğitmen Sınıf Konunun adı Görevle ilgili bazı fikirler veya küçük notlar Bir oda, bina veya herhangi bir yer belirtin Etkinlikle ilgili bazı fikirler veya küçük notlar Konu silindi Günlük silindi Görev silindi Hatılatıcı silindi Ek eklendi Ek silindi Etkinlik silindi Görev, tamamlandı olarak işaretlendi Görev, bekleyen olarak işaretlendi Konu belirtmeyi unuttunuz Açıklama girmeyi unuttunuz Hatırlatıcı eklemeyi unuttunuz Gün belirtmeyi unuttunuz Başlangıç tarihini belirtmeyi unuttunuz Bitiş tarihini belirtmeyi unuttunuz Görev adını girmeyi unuttunuz Bitiş tarihini belirtmeyi unuttunuz Etkinlik adını belirtmeyi unuttunuz Konum bilgisini unuttunuz Hatırlatıcı eklemeyi unuttunuz Yedek dosyası geçersiz Yedek dosyası bozulmuş veya okunamıyor Yedeklenecek bir kayıt yok Yedekleme işlemi sırasında bir sorunla karşılaşıldı İçe aktarma devam ediyor İçe aktarma başarılı İçe aktarma sırasında bir sorunla karşılaşıldı Dışarı aktarma devam ediyor Dışa aktarma başarılı Dışa aktarma sırasında bir sorunla karşılaşıldı Paylaşılamıyor Paylaşılabilecek geçerli bir bilgi yok Aynı isimde bir konu zaten mevcut. Hatırlatıcı başka bir hatırlatıcı ile çakışıyor. Aynı isimde başka bir görev zaten mevcut. Aynı isimde başka bir etkinlik zaten mevcut. Aynı hatırlatıcıya sahip başka bir etkinlik zaten mevcut. Arayüz Ses Bildirimler Gelişmiş Hata Ayıklama Katkıda Bulun Tema Sistem varsayılanı Karanlık Açık Konfeti Görevler tamamlandığında konfeti efekti göster Tamamlanma Sesleri Bir görev tamamlandı olarak işaretlendiğinde ses çal Hatırlatma sıklığı Her Gün Hafta Sonu Şu saatte hatırlat Bir görevin bitiş tarihi yaklaştığında Son teslim tarihine yaklaşan görevler için bildirimleri göster Görev hatırlatma aralığı 1 saat önce 3 saat önce 1 gün önce Yaklaşan etkinlikler Bugün yaklaşan etkinliklerle ilgili bildirimleri göster Etkinlik hatırlatma aralığı 15 dakika önce 30 dakika önce @string/settings_task_reminders_item_hour Bugünkü dersler Bugünkü derslerimle ilgili bildirimleri göster. Ders hatırlatma aralığı 5 dakika önce @string/settings_event_reminders_item_quarter_hour @string/settings_event_reminders_item_half_hour Daha Çok Ayar Hafta Numaralarını Etkinleştir Hatırlatıcıları ayarlarken hafta numaralarını gösterin. @string/activity_backup Yedekle Hiç Yedekleme Yapılmamış Geri Yükle Bağlantıları harici tarayıcıda aç Web site linklerini işlemek için sistem varsayılan tarayıcısını kullanın @string/button_learn_more Bazen, telefon üreticinizin agresif pil optimizasyonu nedeniyle bildiriminiz tetiklenmeyebilir. Çevir Sorun bildir @string/activity_notices Uygulama Versiyonu Gökyüzü Çimen Gün Batımı Limon Deniz Üzüm Kiraz Mercan Gece Mavisi Nane Lavanta Grafit Görev Widget\'ı Görevler Bugün göreviniz yok Etkinlik Widget\'ı Etkinlikler Bugün etkinliğiniz yok Ders Widget\'ı Dersler Bugün dersiniz yok Bunu önemli olarak işaretlemek, sistem tepsisinde kalıcı bir bildirim olarak gösterileceği anlamına gelir. Hatırlatıcılar Genel Görev Hatırlatıcıları Etkinlik Hatırlatıcıları Ders Hatırlatıcıları Bekleyen %1$d göreviniz var Göz atmak isteyebilirsiniz Yedekleniyor Yedekleme Başarısız Yedekleme Başarılı Geri Yükleniyor Geri Yükleme Başarısız Geri Yükleme Başarılı Göreve Ekle İyi günler! Günaydın! Tünaydın! İyi akşamlar!  ve  Dün şu saatte %1$s Dün Yarın şu saatte %1$s Yarın Bugün şu saatte %1$s Bugün Bugün şu saatte bitiyor %1$s Yarın şu saatte bitiyor %1$s Açık Kaynak Kodlu Kütüphaneler Diğer Kaynaklar Zapsplat Bildirim Sesi Freepik Uygulama Simgesi Tailwind Labs Arayüz Simgeleri ================================================ FILE: app/src/main/res/values-v23/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-v27/themes.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_about_main.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_about_notices.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_launcher_shortcuts.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_provider_paths.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_settings_backups.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_settings_main.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_widget_events.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_widget_subjects.xml ================================================ ================================================ FILE: app/src/main/res/xml/xml_widget_tasks.xml ================================================ ================================================ FILE: app/src/test/java/com/isaiahvonrundstedt/fokus/ExampleUnitTest.kt ================================================ package com.isaiahvonrundstedt.fokus import org.junit.Test import org.junit.Assert.* /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } } ================================================ FILE: app/src/test/java/com/isaiahvonrundstedt/fokus/features/attachments/AttachmentTests.kt ================================================ package com.isaiahvonrundstedt.fokus.features.attachments import org.junit.Test import org.junit.Assert.* class AttachmentTests { private val path = "/storage/emulated/0/Android/data/com.isaiahvonrundstedt.fokus/files/image.jpg" @Test fun `Get attachment file type and check if it's an image`() { assertTrue(Attachment.isImage(path)) } } ================================================ FILE: app/src/test/java/com/isaiahvonrundstedt/fokus/features/event/EventUnitTest.kt ================================================ package com.isaiahvonrundstedt.fokus.features.event import org.junit.Test import java.time.ZonedDateTime class EventUnitTest { @Test fun `should return true if schedule is today`() { val event = Event(schedule = ZonedDateTime.now()) assert(event.isToday()) } } ================================================ FILE: app/src/test/java/com/isaiahvonrundstedt/fokus/features/schedule/ScheduleTests.kt ================================================ package com.isaiahvonrundstedt.fokus.features.schedule import org.junit.Assert.assertTrue import org.junit.Test import java.time.DayOfWeek class ScheduleTests { @Test fun `Check schedule if it's Monday Wednesday and Thursday`() { val daysOfWeek = Schedule.BIT_VALUE_MONDAY + Schedule.BIT_VALUE_WEDNESDAY + Schedule.BIT_VALUE_THURSDAY assertTrue(Schedule.parseDaysOfWeek(daysOfWeek).contains(DayOfWeek.MONDAY.value)) assertTrue(Schedule.parseDaysOfWeek(daysOfWeek).contains(DayOfWeek.WEDNESDAY.value)) assertTrue(Schedule.parseDaysOfWeek(daysOfWeek).contains(DayOfWeek.THURSDAY.value)) } } ================================================ FILE: app/src/test/java/com/isaiahvonrundstedt/fokus/features/task/TaskUnitTest.kt ================================================ package com.isaiahvonrundstedt.fokus.features.task import org.junit.Test import java.time.ZonedDateTime class TaskUnitTest { @Test fun `should return true when a task has due date`() { val task = Task() task.dueDate = ZonedDateTime.now() assert(task.hasDueDate()) } @Test fun `should return true when the task has due date in the future`() { val task = Task() task.dueDate = ZonedDateTime.now().plusDays(1) assert(task.isDueDateInFuture()) } @Test fun `should return true when task has due date is today`() { val task = Task() task.dueDate = ZonedDateTime.now() assert(task.isDueToday()) } } ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.kotlin_version = '1.7.10' repositories { google() mavenCentral() maven { url 'https://jitpack.io' } maven { url "https://plugins.gradle.org/m2/" } } dependencies { classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:8.9.4' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { google() jcenter() mavenCentral() maven { url 'https://jitpack.io' } } } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: fastlane/metadata/android/de/short_description.txt ================================================ Aufgaben- und Kalender-App für Studenten ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================

Fokus is an open source application that combines a todo list and a calendar that can help you manage your school related work and events in one place. It’s fast and beautiful yet simple design that can help you focus on what matters most.


Features:

  • Get reminded when a task is nearing its due
  • Get reminded about incoming events
  • Add attachments to your tasks
  • Persistent notifications for important tasks or events
  • No ads or any tracking
  • Open Source Code
  • On-Device Database
================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ To Do app tailored specifically for students ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Wed May 10 13:15:17 IST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS="" # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin, switch paths to Windows format before running java if $cygwin ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=$((i+1)) done case $i in (0) set -- ;; (1) set -- "$args0" ;; (2) set -- "$args0" "$args1" ;; (3) set -- "$args0" "$args1" "$args2" ;; (4) set -- "$args0" "$args1" "$args2" "$args3" ;; (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=$(save "$@") # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then cd "$(dirname "$0")" fi exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS= @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto init echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto init echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :init @rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args :win9xME_args @rem Slurp the command line arguments. set CMD_LINE_ARGS= set _SKIP=2 :win9xME_args_slurp if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: settings.gradle ================================================ rootProject.name='Fokus' include ':app'