From 47321c7ae6100b1476f9af9357b3de0f3b8d0842 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Mon, 11 Mar 2024 19:33:12 +0300 Subject: [PATCH] I'm always forget to commit --- .idea/androidTestResultsUserPreferences.xml | 22 + .idea/appInsightsSettings.xml | 6 + .idea/deploymentTargetDropDown.xml | 18 +- .idea/other.xml | 6 + app/build.gradle.kts | 31 +- .../androidTest/assets/categories/simple.json | 9 + app/src/androidTest/assets/kotatsu_test.bak | Bin 0 -> 6136 bytes app/src/androidTest/assets/manga/bad_ids.json | 163 +++++ app/src/androidTest/assets/manga/empty.json | 36 ++ .../assets/manga/first_chapters.json | 136 +++++ app/src/androidTest/assets/manga/full.json | 163 +++++ app/src/androidTest/assets/manga/header.json | 35 ++ .../assets/manga/without_middle_chapter.json | 154 +++++ .../xtimms/tokusho/ExampleInstrumentedTest.kt | 24 - .../java/org/xtimms/tokusho/HiltTestRunner.kt | 13 + .../org/xtimms/tokusho/Instrumentation.kt | 9 + .../java/org/xtimms/tokusho/SampleData.kt | 59 ++ .../settings/backup/AppBackupAgentTest.kt | 109 ++++ app/src/main/AndroidManifest.xml | 3 +- app/src/main/assets/font/manrope_variable.ttf | Bin 0 -> 158292 bytes app/src/main/java/org/xtimms/tokusho/App.kt | 6 + .../org/xtimms/tokusho/CompositionLocals.kt | 2 +- .../java/org/xtimms/tokusho/MainActivity.kt | 38 +- .../java/org/xtimms/tokusho/TokushoModule.kt | 15 + .../org/xtimms/tokusho/core/AsyncImageImpl.kt | 15 +- .../org/xtimms/tokusho/core/Navigation.kt | 172 +++++- .../base/viewmodel/KotatsuBaseViewModel.kt | 79 +++ .../tokusho/core/components/AnimatedButton.kt | 114 ++++ .../core/components/BackgroundProgress.kt | 81 +++ .../tokusho/core/components/DetailsToolbar.kt | 84 ++- .../core/components/DotSeparatorText.kt | 25 + .../core/components/FloatingActionButton.kt | 125 ++++ .../tokusho/core/components/IconButtons.kt | 29 + .../tokusho/core/components/MangaCover.kt | 2 +- .../tokusho/core/components/MangaGridItem.kt | 78 ++- .../tokusho/core/components/PreferenceItem.kt | 6 +- .../tokusho/core/components/PullRefresh.kt | 290 +++++++++ .../tokusho/core/components/ReadButton.kt | 187 ++++++ .../core/components/ScaffoldWithTopAppBar.kt | 5 + .../tokusho/core/components/ScoreIndicator.kt | 54 ++ .../tokusho/core/components/TopAppBar.kt | 31 +- .../core/components/effects/Snowflake.kt | 60 ++ .../components/icons/ArrowDecisionOutline.kt | 53 ++ .../tokusho/core/components/icons/Kotatsu.kt | 293 +++++++++ .../core/components/shape/WavyShape.kt | 46 ++ .../database/DatabasePrePopulateCallback.kt | 79 +++ .../tokusho/core/database/TokushoDatabase.kt | 12 +- .../tokusho/core/database/dao/BookmarksDao.kt | 57 ++ .../core/database/dao/FavouritesDao.kt | 3 + .../core/database/dao/MangaSourcesDao.kt | 52 ++ .../tokusho/core/database/dao/TagsDao.kt | 88 +++ .../core/database/entity/BookmarksEntity.kt | 28 + .../core/database/entity/EntityMapping.kt | 30 + .../CloudflareProtectedException.kt | 11 + .../org/xtimms/tokusho/core/model/Bookmark.kt | 42 ++ .../xtimms/tokusho/core/model/LocalManga.kt | 42 ++ .../org/xtimms/tokusho/core/model/Manga.kt | 58 +- .../xtimms/tokusho/core/model/MangaSource.kt | 5 +- .../tokusho/core/network/NetworkModule.kt | 6 + .../xtimms/tokusho/core/network/SSLBypass.kt | 29 + .../interceptors/CloudflareInterceptor.kt | 32 + .../interceptors/CommonHeadersInterceptor.kt | 7 +- .../core/parser/MangaDataRepository.kt | 11 + .../tokusho/core/parser/MangaRepository.kt | 5 + .../core/parser/RemoteMangaRepository.kt | 10 + .../tokusho/core/parser/local/CbzFilter.kt | 22 + .../core/parser/local/DownloadFormat.kt | 8 + .../core/parser/local/LocalMangaRepository.kt | 220 +++++++ .../tokusho/core/parser/local/MangaIndex.kt | 200 +++++++ .../tokusho/core/parser/local/Qualifiers.kt | 7 + .../core/parser/local/TempFileFilter.kt | 11 + .../parser/local/input/LocalMangaDirInput.kt | 158 +++++ .../parser/local/input/LocalMangaInput.kt | 111 ++++ .../parser/local/input/LocalMangaZipInput.kt | 154 +++++ .../local/output/LocalMangaDirOutput.kt | 136 +++++ .../parser/local/output/LocalMangaOutput.kt | 109 ++++ .../parser/local/output/LocalMangaUtil.kt | 45 ++ .../local/output/LocalMangaZipOutput.kt | 156 +++++ .../xtimms/tokusho/core/prefs/AppSettings.kt | 14 +- .../tokusho/core/prefs/KotatsuAppSettings.kt | 89 +++ .../tokusho/core/screens/EmptyScreen.kt | 46 +- .../xtimms/tokusho/core/updates/Updater.kt | 4 +- .../org/xtimms/tokusho/core/zip/ZipOutput.kt | 122 ++++ .../tokusho/data/LocalStorageManager.kt | 29 + .../data/repository/BookmarksRepository.kt | 102 ++++ .../data/repository/ExploreRepository.kt | 40 ++ .../data/repository/FavouritesRepository.kt | 75 +++ .../data/repository/HistoryRepository.kt | 49 ++ .../data/repository/MangaSourcesRepository.kt | 113 ++++ .../data/repository/backup/BackupEntry.kt | 21 + .../repository/backup/BackupRepository.kt | 200 +++++++ .../data/repository/backup/BackupZipInput.kt | 44 ++ .../data/repository/backup/BackupZipOutput.kt | 46 ++ .../data/repository/backup/CompositeResult.kt | 42 ++ .../repository/backup/JsonDeserializer.kt | 100 ++++ .../data/repository/backup/JsonSerializer.kt | 99 ++++ .../sections/details/ChapterListItem.kt | 127 ++++ .../sections/details/ChaptersMapper.kt | 63 ++ .../sections/details/DetailsInfoHeader.kt | 559 ++++++++++++++---- .../tokusho/sections/details/DetailsView.kt | 194 +++++- .../sections/details/DetailsViewModel.kt | 156 ++++- .../tokusho/sections/details/FullImageView.kt | 39 +- .../sections/details/data/MangaDetails.kt | 34 ++ .../details/domain/BranchComparator.kt | 8 + .../details/domain/DetailsInteractor.kt | 25 + .../details/domain/DetailsLoadUseCase.kt | 19 +- .../details/domain/RelatedMangaUseCase.kt | 17 + .../sections/details/model/ChapterItem.kt | 94 +++ .../sections/details/model/HistoryInfo.kt | 30 + .../details/model/ListModelConversionExt.kt | 28 + .../sections/details/model/MangaBranch.kt | 19 + .../tokusho/sections/explore/ExploreView.kt | 148 +++-- .../sections/explore/ExploreViewModel.kt | 57 +- .../sections/explore/SourceItemModel.kt | 16 + .../sections/explore/data/SourcesSortOrder.kt | 12 + .../xtimms/tokusho/sections/feed/FeedView.kt | 52 ++ .../tokusho/sections/history/HistoryView.kt | 8 +- .../tokusho/sections/list/MangaListView.kt | 101 ++-- .../tokusho/sections/search/SearchView.kt | 8 +- .../tokusho/sections/settings/SettingsView.kt | 97 +-- .../sections/settings/SettingsViewModel.kt | 58 ++ .../sections/settings/about/AboutView.kt | 10 + .../sections/settings/about/LicenseView.kt | 66 +++ .../settings/about/OpenSourceLicensesView.kt | 37 ++ .../settings/appearance/AppearanceView.kt | 3 +- .../settings/backup/AppBackupAgent.kt | 118 ++++ .../settings/backup/BackupEntryModel.kt | 28 + .../sections/settings/backup/BackupItem.kt | 65 ++ .../settings/backup/BackupObserver.kt | 23 + .../settings/backup/BackupRestoreView.kt | 218 +++++++ .../settings/backup/BackupViewModel.kt | 52 ++ .../settings/backup/PeriodicalBackupWorker.kt | 98 +++ .../settings/backup/RestoreItemsView.kt | 107 ++++ .../settings/backup/RestoreViewModel.kt | 149 +++++ .../sections/settings/network/NetworkView.kt | 76 +++ .../settings/shelf/ShelfSettingsView.kt | 29 +- .../shelf/categories/AddCategoryDialog.kt | 48 ++ .../shelf/categories/CategoriesView.kt | 52 +- .../shelf/categories/CategoryListItem.kt | 85 +++ .../categories/interactor/ReorderCategory.kt | 61 ++ .../settings/sources/SourcesSettingsView.kt | 101 ++++ .../sources/SourcesSettingsViewModel.kt | 26 + .../sources/catalog/SourceCatalogItem.kt | 44 ++ .../sources/catalog/SourceCatalogItemModel.kt | 30 + .../sources/catalog/SourceCatalogPage.kt | 14 + .../catalog/SourcesCatalogListProducer.kt | 108 ++++ .../sources/catalog/SourcesCatalogTabs.kt | 59 ++ .../sources/catalog/SourcesCatalogView.kt | 46 ++ .../catalog/SourcesCatalogViewModel.kt | 87 +++ .../sections/settings/storage/StorageView.kt | 2 +- .../sections/shelf/FavouriteTabModel.kt | 14 + .../tokusho/sections/shelf/LazyShelfGrid.kt | 28 + .../tokusho/sections/shelf/ShelfGrid.kt | 41 ++ .../tokusho/sections/shelf/ShelfManga.kt | 15 - .../tokusho/sections/shelf/ShelfPager.kt | 30 +- .../tokusho/sections/shelf/ShelfTabs.kt | 7 +- .../tokusho/sections/shelf/ShelfUiState.kt | 14 - .../tokusho/sections/shelf/ShelfView.kt | 92 +-- .../tokusho/sections/shelf/ShelfViewModel.kt | 42 +- .../shelf/ext/ShelfCategoryExtensions.kt | 20 - .../tokusho/sections/stats/ChaptersChart.kt | 4 +- .../tokusho/sections/stats/MinMaxReadCard.kt | 6 +- .../java/org/xtimms/tokusho/ui/theme/Color.kt | 2 +- .../java/org/xtimms/tokusho/ui/theme/Theme.kt | 10 +- .../java/org/xtimms/tokusho/ui/theme/Type.kt | 90 ++- .../tokusho/utils/AlphanumComparator.kt | 63 ++ .../xtimms/tokusho/utils/CompositeMutex.kt | 58 ++ .../xtimms/tokusho/utils/CompositeMutex2.kt | 43 ++ .../org/xtimms/tokusho/utils/CrashLogUtil.kt | 5 +- .../org/xtimms/tokusho/utils/FileSequence.kt | 15 + .../xtimms/tokusho/utils/ImageFileFilter.kt | 11 + .../utils/RetainedLifecycleCoroutineScope.kt | 23 + .../xtimms/tokusho/utils/ReversibleAction.kt | 8 + .../tokusho/utils/StringArrayNavType.kt | 21 + .../xtimms/tokusho/utils/composable/Bitmap.kt | 21 + .../tokusho/utils/composable/LazyListState.kt | 59 ++ .../tokusho/utils/composable/Modifier.kt | 15 + .../utils/iterator/CloseableIterator.kt | 36 ++ .../tokusho/utils/iterator/MappingIterator.kt | 11 + .../org/xtimms/tokusho/utils/lang/Android.kt | 16 + .../xtimms/tokusho/utils/lang/Collections.kt | 11 + .../xtimms/tokusho/utils/lang/Coroutines.kt | 71 +-- .../xtimms/tokusho/utils/lang/FlowObserver.kt | 37 ++ .../xtimms/tokusho/utils/lang/Primitive.kt | 5 +- .../org/xtimms/tokusho/utils/lang/String.kt | 7 +- .../xtimms/tokusho/utils/lang/WorkManager.kt | 11 + .../org/xtimms/tokusho/utils/system/File.kt | 39 +- .../org/xtimms/tokusho/utils/system/Locale.kt | 21 +- .../org/xtimms/tokusho/utils/system/Uri.kt | 4 + .../tokusho/work/PeriodicWorkScheduler.kt | 10 + .../drawable/baseline_error_outline_24.xml | 5 + app/src/main/res/drawable/cover_error.xml | 25 + app/src/main/res/drawable/cover_loading.xml | 25 + app/src/main/res/font/roboto_flex_regular.ttf | Bin 0 -> 109860 bytes app/src/main/res/values-v31/themes.xml | 10 + app/src/main/res/values/colors.xml | 8 +- app/src/main/res/values/plurals.xml | 4 + app/src/main/res/values/strings.xml | 80 +++ app/src/main/res/values/themes.xml | 7 +- .../repository/backup/JsonSerializerTest.kt | 98 +++ build.gradle.kts | 1 + 201 files changed, 10479 insertions(+), 696 deletions(-) create mode 100644 .idea/androidTestResultsUserPreferences.xml create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/other.xml create mode 100644 app/src/androidTest/assets/categories/simple.json create mode 100644 app/src/androidTest/assets/kotatsu_test.bak create mode 100644 app/src/androidTest/assets/manga/bad_ids.json create mode 100644 app/src/androidTest/assets/manga/empty.json create mode 100644 app/src/androidTest/assets/manga/first_chapters.json create mode 100644 app/src/androidTest/assets/manga/full.json create mode 100644 app/src/androidTest/assets/manga/header.json create mode 100644 app/src/androidTest/assets/manga/without_middle_chapter.json delete mode 100644 app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt create mode 100644 app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt create mode 100644 app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt create mode 100644 app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt create mode 100644 app/src/main/assets/font/manrope_variable.ttf create mode 100644 app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt delete mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt delete mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt create mode 100644 app/src/main/res/drawable/baseline_error_outline_24.xml create mode 100644 app/src/main/res/drawable/cover_error.xml create mode 100644 app/src/main/res/drawable/cover_loading.xml create mode 100644 app/src/main/res/font/roboto_flex_regular.ttf create mode 100644 app/src/main/res/values-v31/themes.xml create mode 100644 app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..b6c1b48 --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..23b2e1f --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 8184c57..93a3134 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -2,21 +2,11 @@ + + + - - - - - - - - - - - - - - + diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 0000000..f3d4a2e --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 612ffe8..13b2ceb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,7 @@ plugins { id("org.jetbrains.kotlin.kapt") id("org.jetbrains.kotlin.plugin.parcelize") id("com.google.devtools.ksp") + id("com.mikepenz.aboutlibraries.plugin") id("dagger.hilt.android.plugin") } @@ -36,7 +37,7 @@ android { buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin) buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword) - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "org.xtimms.tokusho.HiltTestRunner" vectorDrawables { useSupportLibrary = true } @@ -85,20 +86,22 @@ android { dependencies { implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-process:2.7.0") implementation("androidx.activity:activity-compose:1.8.2") - implementation(platform("androidx.compose:compose-bom:2024.01.00")) + implementation(platform("dev.chrisbanes.compose:compose-bom:2024.02.00-alpha02")) implementation("androidx.compose.animation:animation-graphics") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material:material-icons-extended:1.6.0") - implementation("androidx.compose.material3:material3-android:1.2.0") - implementation("androidx.compose.material3:material3-window-size-class:1.2.0") - implementation("androidx.hilt:hilt-navigation-compose:1.1.0") - implementation("androidx.navigation:navigation-compose:2.7.6") + implementation("androidx.compose.material:material-icons-extended:1.6.3") + implementation("androidx.compose.material3:material3-android:1.2.1") + implementation("androidx.compose.material3:material3-window-size-class:1.2.1") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.preference:preference-ktx:1.2.1") implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") @@ -112,12 +115,15 @@ dependencies { implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0") implementation("com.google.dagger:hilt-android:2.50") kapt("com.google.dagger:hilt-compiler:2.50") - implementation("androidx.hilt:hilt-work:1.1.0") - kapt("androidx.hilt:hilt-compiler:1.1.0") - implementation("com.github.KotatsuApp:kotatsu-parsers:a8f9423307") + implementation("androidx.hilt:hilt-work:1.2.0") + kapt("androidx.hilt:hilt-compiler:1.2.0") + implementation("com.github.KotatsuApp:kotatsu-parsers:3ff028c4e9") { + exclude(group = "org.json", module = "json") + } + implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0") - implementation("com.squareup.okio:okio:3.7.0") + implementation("com.squareup.okio:okio:3.8.0") implementation("com.tencent:mmkv:1.3.2") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3") @@ -128,6 +134,9 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1") + androidTestImplementation("com.google.dagger:hilt-android-testing:2.50") + kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.50") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/app/src/androidTest/assets/categories/simple.json b/app/src/androidTest/assets/categories/simple.json new file mode 100644 index 0000000..38f6ab6 --- /dev/null +++ b/app/src/androidTest/assets/categories/simple.json @@ -0,0 +1,9 @@ +{ + "id": 4, + "title": "Read later", + "sortKey": 1, + "order": "NEWEST", + "createdAt": 1335906000000, + "isTrackingEnabled": true, + "isVisibleInLibrary": true +} \ No newline at end of file diff --git a/app/src/androidTest/assets/kotatsu_test.bak b/app/src/androidTest/assets/kotatsu_test.bak new file mode 100644 index 0000000000000000000000000000000000000000..a6eae4cdc6a931546d5020bad83b3694184790ea GIT binary patch literal 6136 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 Up^S$9_e|73ZQ;*|P5yWFUpiM!p8x;= literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/manga/bad_ids.json b/app/src/androidTest/assets/manga/bad_ids.json new file mode 100644 index 0000000..7058634 --- /dev/null +++ b/app/src/androidTest/assets/manga/bad_ids.json @@ -0,0 +1,163 @@ +{ + "id": -2096681732556647985, + "title": "Странствия Эманон", + "url": "/stranstviia_emanon", + "publicUrl": "https://readmanga.io/stranstviia_emanon", + "rating": 0.9400894, + "isNsfw": true, + "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg", + "tags": [ + { + "title": "Сверхъестественное", + "key": "supernatural", + "source": "READMANGA_RU" + }, + { + "title": "Сэйнэн", + "key": "seinen", + "source": "READMANGA_RU" + }, + { + "title": "Повседневность", + "key": "slice_of_life", + "source": "READMANGA_RU" + }, + { + "title": "Приключения", + "key": "adventure", + "source": "READMANGA_RU" + } + ], + "state": "FINISHED", + "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", + "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", + "chapters": [ + { + "id": 1552943969433540704, + "name": "1 - 1", + "number": 1, + "url": "/stranstviia_emanon/vol1/1", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433540705, + "name": "1 - 2", + "number": 2, + "url": "/stranstviia_emanon/vol1/2", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433540706, + "name": "1 - 3", + "number": 3, + "url": "/stranstviia_emanon/vol1/3", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433540707, + "name": "1 - 4", + "number": 4, + "url": "/stranstviia_emanon/vol1/4", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433540708, + "name": "1 - 5", + "number": 5, + "url": "/stranstviia_emanon/vol1/5", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433541665, + "name": "2 - 1", + "number": 6, + "url": "/stranstviia_emanon/vol2/1", + "scanlator": "Sup!", + "uploadDate": 1415570400000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433541666, + "name": "2 - 2", + "number": 7, + "url": "/stranstviia_emanon/vol2/2", + "scanlator": "Sup!", + "uploadDate": 1419976800000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433541667, + "name": "2 - 3", + "number": 8, + "url": "/stranstviia_emanon/vol2/3", + "scanlator": "Sup!", + "uploadDate": 1427922000000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433541668, + "name": "2 - 4", + "number": 9, + "url": "/stranstviia_emanon/vol2/4", + "scanlator": "Sup!", + "uploadDate": 1436907600000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433541669, + "name": "2 - 5", + "number": 10, + "url": "/stranstviia_emanon/vol2/5", + "scanlator": "Sup!", + "uploadDate": 1446674400000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433541670, + "name": "2 - 6", + "number": 11, + "url": "/stranstviia_emanon/vol2/6", + "scanlator": "Sup!", + "uploadDate": 1451512800000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433542626, + "name": "3 - 1", + "number": 12, + "url": "/stranstviia_emanon/vol3/1", + "scanlator": "Sup!", + "uploadDate": 1461618000000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433542627, + "name": "3 - 2", + "number": 13, + "url": "/stranstviia_emanon/vol3/2", + "scanlator": "Sup!", + "uploadDate": 1461618000000, + "source": "READMANGA_RU" + }, + { + "id": 1552943969433542628, + "name": "3 - 3", + "number": 14, + "url": "/stranstviia_emanon/vol3/3", + "scanlator": "", + "uploadDate": 1465851600000, + "source": "READMANGA_RU" + } + ], + "source": "READMANGA_RU" +} \ No newline at end of file diff --git a/app/src/androidTest/assets/manga/empty.json b/app/src/androidTest/assets/manga/empty.json new file mode 100644 index 0000000..91838e2 --- /dev/null +++ b/app/src/androidTest/assets/manga/empty.json @@ -0,0 +1,36 @@ +{ + "id": -2096681732556647985, + "title": "Странствия Эманон", + "url": "/stranstviia_emanon", + "publicUrl": "https://readmanga.io/stranstviia_emanon", + "rating": 0.9400894, + "isNsfw": true, + "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg", + "tags": [ + { + "title": "Сверхъестественное", + "key": "supernatural", + "source": "READMANGA_RU" + }, + { + "title": "Сэйнэн", + "key": "seinen", + "source": "READMANGA_RU" + }, + { + "title": "Повседневность", + "key": "slice_of_life", + "source": "READMANGA_RU" + }, + { + "title": "Приключения", + "key": "adventure", + "source": "READMANGA_RU" + } + ], + "state": "FINISHED", + "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", + "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", + "chapters": [], + "source": "READMANGA_RU" +} \ No newline at end of file diff --git a/app/src/androidTest/assets/manga/first_chapters.json b/app/src/androidTest/assets/manga/first_chapters.json new file mode 100644 index 0000000..9e1c1d7 --- /dev/null +++ b/app/src/androidTest/assets/manga/first_chapters.json @@ -0,0 +1,136 @@ +{ + "id": -2096681732556647985, + "title": "Странствия Эманон", + "url": "/stranstviia_emanon", + "publicUrl": "https://readmanga.io/stranstviia_emanon", + "rating": 0.9400894, + "isNsfw": true, + "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg", + "tags": [ + { + "title": "Сверхъестественное", + "key": "supernatural", + "source": "READMANGA_RU" + }, + { + "title": "Сэйнэн", + "key": "seinen", + "source": "READMANGA_RU" + }, + { + "title": "Повседневность", + "key": "slice_of_life", + "source": "READMANGA_RU" + }, + { + "title": "Приключения", + "key": "adventure", + "source": "READMANGA_RU" + } + ], + "state": "FINISHED", + "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", + "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", + "chapters": [ + { + "id": 3552943969433540704, + "name": "1 - 1", + "number": 1, + "url": "/stranstviia_emanon/vol1/1", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540705, + "name": "1 - 2", + "number": 2, + "url": "/stranstviia_emanon/vol1/2", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540706, + "name": "1 - 3", + "number": 3, + "url": "/stranstviia_emanon/vol1/3", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540707, + "name": "1 - 4", + "number": 4, + "url": "/stranstviia_emanon/vol1/4", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540708, + "name": "1 - 5", + "number": 5, + "url": "/stranstviia_emanon/vol1/5", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541665, + "name": "2 - 1", + "number": 6, + "url": "/stranstviia_emanon/vol2/1", + "scanlator": "Sup!", + "uploadDate": 1415570400000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541666, + "name": "2 - 2", + "number": 7, + "url": "/stranstviia_emanon/vol2/2", + "scanlator": "Sup!", + "uploadDate": 1419976800000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541667, + "name": "2 - 3", + "number": 8, + "url": "/stranstviia_emanon/vol2/3", + "scanlator": "Sup!", + "uploadDate": 1427922000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541668, + "name": "2 - 4", + "number": 9, + "url": "/stranstviia_emanon/vol2/4", + "scanlator": "Sup!", + "uploadDate": 1436907600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541669, + "name": "2 - 5", + "number": 10, + "url": "/stranstviia_emanon/vol2/5", + "scanlator": "Sup!", + "uploadDate": 1446674400000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541670, + "name": "2 - 6", + "number": 11, + "url": "/stranstviia_emanon/vol2/6", + "scanlator": "Sup!", + "uploadDate": 1451512800000, + "source": "READMANGA_RU" + } + ], + "source": "READMANGA_RU" +} \ No newline at end of file diff --git a/app/src/androidTest/assets/manga/full.json b/app/src/androidTest/assets/manga/full.json new file mode 100644 index 0000000..685e424 --- /dev/null +++ b/app/src/androidTest/assets/manga/full.json @@ -0,0 +1,163 @@ +{ + "id": -2096681732556647985, + "title": "Странствия Эманон", + "url": "/stranstviia_emanon", + "publicUrl": "https://readmanga.io/stranstviia_emanon", + "rating": 0.9400894, + "isNsfw": true, + "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg", + "tags": [ + { + "title": "Сверхъестественное", + "key": "supernatural", + "source": "READMANGA_RU" + }, + { + "title": "Сэйнэн", + "key": "seinen", + "source": "READMANGA_RU" + }, + { + "title": "Повседневность", + "key": "slice_of_life", + "source": "READMANGA_RU" + }, + { + "title": "Приключения", + "key": "adventure", + "source": "READMANGA_RU" + } + ], + "state": "FINISHED", + "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", + "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", + "chapters": [ + { + "id": 3552943969433540704, + "name": "1 - 1", + "number": 1, + "url": "/stranstviia_emanon/vol1/1", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540705, + "name": "1 - 2", + "number": 2, + "url": "/stranstviia_emanon/vol1/2", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540706, + "name": "1 - 3", + "number": 3, + "url": "/stranstviia_emanon/vol1/3", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540707, + "name": "1 - 4", + "number": 4, + "url": "/stranstviia_emanon/vol1/4", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540708, + "name": "1 - 5", + "number": 5, + "url": "/stranstviia_emanon/vol1/5", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541665, + "name": "2 - 1", + "number": 6, + "url": "/stranstviia_emanon/vol2/1", + "scanlator": "Sup!", + "uploadDate": 1415570400000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541666, + "name": "2 - 2", + "number": 7, + "url": "/stranstviia_emanon/vol2/2", + "scanlator": "Sup!", + "uploadDate": 1419976800000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541667, + "name": "2 - 3", + "number": 8, + "url": "/stranstviia_emanon/vol2/3", + "scanlator": "Sup!", + "uploadDate": 1427922000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541668, + "name": "2 - 4", + "number": 9, + "url": "/stranstviia_emanon/vol2/4", + "scanlator": "Sup!", + "uploadDate": 1436907600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541669, + "name": "2 - 5", + "number": 10, + "url": "/stranstviia_emanon/vol2/5", + "scanlator": "Sup!", + "uploadDate": 1446674400000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541670, + "name": "2 - 6", + "number": 11, + "url": "/stranstviia_emanon/vol2/6", + "scanlator": "Sup!", + "uploadDate": 1451512800000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433542626, + "name": "3 - 1", + "number": 12, + "url": "/stranstviia_emanon/vol3/1", + "scanlator": "Sup!", + "uploadDate": 1461618000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433542627, + "name": "3 - 2", + "number": 13, + "url": "/stranstviia_emanon/vol3/2", + "scanlator": "Sup!", + "uploadDate": 1461618000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433542628, + "name": "3 - 3", + "number": 14, + "url": "/stranstviia_emanon/vol3/3", + "scanlator": "", + "uploadDate": 1465851600000, + "source": "READMANGA_RU" + } + ], + "source": "READMANGA_RU" +} \ No newline at end of file diff --git a/app/src/androidTest/assets/manga/header.json b/app/src/androidTest/assets/manga/header.json new file mode 100644 index 0000000..5e53ed5 --- /dev/null +++ b/app/src/androidTest/assets/manga/header.json @@ -0,0 +1,35 @@ +{ + "id": -2096681732556647985, + "title": "Странствия Эманон", + "url": "/stranstviia_emanon", + "publicUrl": "https://readmanga.io/stranstviia_emanon", + "rating": 0.9400894, + "isNsfw": true, + "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg", + "tags": [ + { + "title": "Сверхъестественное", + "key": "supernatural", + "source": "READMANGA_RU" + }, + { + "title": "Сэйнэн", + "key": "seinen", + "source": "READMANGA_RU" + }, + { + "title": "Повседневность", + "key": "slice_of_life", + "source": "READMANGA_RU" + }, + { + "title": "Приключения", + "key": "adventure", + "source": "READMANGA_RU" + } + ], + "state": "FINISHED", + "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", + "description": null, + "source": "READMANGA_RU" +} \ No newline at end of file diff --git a/app/src/androidTest/assets/manga/without_middle_chapter.json b/app/src/androidTest/assets/manga/without_middle_chapter.json new file mode 100644 index 0000000..97d797b --- /dev/null +++ b/app/src/androidTest/assets/manga/without_middle_chapter.json @@ -0,0 +1,154 @@ +{ + "id": -2096681732556647985, + "title": "Странствия Эманон", + "url": "/stranstviia_emanon", + "publicUrl": "https://readmanga.io/stranstviia_emanon", + "rating": 0.9400894, + "isNsfw": true, + "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg", + "tags": [ + { + "title": "Сверхъестественное", + "key": "supernatural", + "source": "READMANGA_RU" + }, + { + "title": "Сэйнэн", + "key": "seinen", + "source": "READMANGA_RU" + }, + { + "title": "Повседневность", + "key": "slice_of_life", + "source": "READMANGA_RU" + }, + { + "title": "Приключения", + "key": "adventure", + "source": "READMANGA_RU" + } + ], + "state": "FINISHED", + "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", + "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", + "chapters": [ + { + "id": 3552943969433540704, + "name": "1 - 1", + "number": 1, + "url": "/stranstviia_emanon/vol1/1", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540705, + "name": "1 - 2", + "number": 2, + "url": "/stranstviia_emanon/vol1/2", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540706, + "name": "1 - 3", + "number": 3, + "url": "/stranstviia_emanon/vol1/3", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540707, + "name": "1 - 4", + "number": 4, + "url": "/stranstviia_emanon/vol1/4", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433540708, + "name": "1 - 5", + "number": 5, + "url": "/stranstviia_emanon/vol1/5", + "scanlator": "Sad-Robot", + "uploadDate": 1342731600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541666, + "name": "2 - 2", + "number": 7, + "url": "/stranstviia_emanon/vol2/2", + "scanlator": "Sup!", + "uploadDate": 1419976800000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541667, + "name": "2 - 3", + "number": 8, + "url": "/stranstviia_emanon/vol2/3", + "scanlator": "Sup!", + "uploadDate": 1427922000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541668, + "name": "2 - 4", + "number": 9, + "url": "/stranstviia_emanon/vol2/4", + "scanlator": "Sup!", + "uploadDate": 1436907600000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541669, + "name": "2 - 5", + "number": 10, + "url": "/stranstviia_emanon/vol2/5", + "scanlator": "Sup!", + "uploadDate": 1446674400000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433541670, + "name": "2 - 6", + "number": 11, + "url": "/stranstviia_emanon/vol2/6", + "scanlator": "Sup!", + "uploadDate": 1451512800000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433542626, + "name": "3 - 1", + "number": 12, + "url": "/stranstviia_emanon/vol3/1", + "scanlator": "Sup!", + "uploadDate": 1461618000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433542627, + "name": "3 - 2", + "number": 13, + "url": "/stranstviia_emanon/vol3/2", + "scanlator": "Sup!", + "uploadDate": 1461618000000, + "source": "READMANGA_RU" + }, + { + "id": 3552943969433542628, + "name": "3 - 3", + "number": 14, + "url": "/stranstviia_emanon/vol3/3", + "scanlator": "", + "uploadDate": 1465851600000, + "source": "READMANGA_RU" + } + ], + "source": "READMANGA_RU" +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt deleted file mode 100644 index b006053..0000000 --- a/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.xtimms.tokusho - -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("org.xtimms.tokusho", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt b/app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt new file mode 100644 index 0000000..719a393 --- /dev/null +++ b/app/src/androidTest/java/org/xtimms/tokusho/HiltTestRunner.kt @@ -0,0 +1,13 @@ +package org.xtimms.tokusho + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt b/app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt new file mode 100644 index 0000000..23266ba --- /dev/null +++ b/app/src/androidTest/java/org/xtimms/tokusho/Instrumentation.kt @@ -0,0 +1,9 @@ +package org.xtimms.tokusho + +import android.app.Instrumentation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun Instrumentation.awaitForIdle() = suspendCoroutine { cont -> + waitForIdle { cont.resume(Unit) } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt b/app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt new file mode 100644 index 0000000..cd95d96 --- /dev/null +++ b/app/src/androidTest/java/org/xtimms/tokusho/SampleData.kt @@ -0,0 +1,59 @@ +package org.xtimms.tokusho + +import androidx.test.platform.app.InstrumentationRegistry +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okio.buffer +import okio.source +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.model.FavouriteCategory +import java.util.Date +import kotlin.reflect.KClass + +object SampleData { + + private val moshi = Moshi.Builder() + .add(DateAdapter()) + .add(KotlinJsonAdapterFactory()) + .build() + + val manga: Manga = loadAsset("manga/header.json", Manga::class) + + val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class) + + val tag = mangaDetails.tags.elementAt(2) + + val chapter = checkNotNull(mangaDetails.chapters)[2] + + val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class) + + fun loadAsset(name: String, cls: KClass): T { + val assets = InstrumentationRegistry.getInstrumentation().context.assets + return assets.open(name).use { + moshi.adapter(cls.java).fromJson(it.source().buffer()) + } ?: throw RuntimeException("Cannot read asset from json \"$name\"") + } + + private class DateAdapter : JsonAdapter() { + + @FromJson + override fun fromJson(reader: JsonReader): Date? { + val ms = reader.nextLong() + return if (ms == 0L) { + null + } else { + Date(ms) + } + } + + @ToJson + override fun toJson(writer: JsonWriter, value: Date?) { + writer.value(value?.time ?: 0L) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt new file mode 100644 index 0000000..7a1008f --- /dev/null +++ b/app/src/androidTest/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgentTest.kt @@ -0,0 +1,109 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.content.res.AssetManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.xtimms.tokusho.SampleData +import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.entity.toMangaTags +import org.xtimms.tokusho.data.repository.FavouritesRepository +import org.xtimms.tokusho.data.repository.HistoryRepository +import org.xtimms.tokusho.data.repository.backup.BackupRepository +import java.io.File +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AppBackupAgentTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var historyRepository: HistoryRepository + + @Inject + lateinit var favouritesRepository: FavouritesRepository + + @Inject + lateinit var backupRepository: BackupRepository + + @Inject + lateinit var database: TokushoDatabase + + @Before + fun setUp() { + hiltRule.inject() + database.clearAllTables() + } + + @Test + fun backupAndRestore() = runTest { + val category = favouritesRepository.createCategory( + title = SampleData.favouriteCategory.title, + sortOrder = SampleData.favouriteCategory.order, + isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled, + isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary, + ) + favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga)) + historyRepository.addOrUpdate( + manga = SampleData.mangaDetails, + chapterId = SampleData.mangaDetails.chapters!![2].id, + page = 3, + scroll = 40, + percent = 0.2f, + ) + val history = checkNotNull(historyRepository.getOne(SampleData.manga)) + + val agent = AppBackupAgent() + val backup = agent.createBackupFile( + context = InstrumentationRegistry.getInstrumentation().targetContext, + repository = backupRepository, + ) + + database.clearAllTables() + assertTrue(favouritesRepository.getAllManga().isEmpty()) + assertNull(historyRepository.getLastOrNull()) + + backup.inputStream().use { + agent.restoreBackupFile(it.fd, backup.length(), backupRepository) + } + + assertEquals(category, favouritesRepository.getCategory(category.id)) + assertEquals(history, historyRepository.getOne(SampleData.manga)) + assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id)) + + val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags() + assertTrue(SampleData.tag in allTags) + } + + @Test + fun restoreOldBackup() { + val agent = AppBackupAgent() + val backup = File.createTempFile("backup_", ".tmp") + InstrumentationRegistry.getInstrumentation().context.assets + .open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING) + .use { input -> + backup.outputStream().use { output -> + input.copyTo(output) + } + } + backup.inputStream().use { + agent.restoreBackupFile(it.fd, backup.length(), backupRepository) + } + runTest { + assertEquals(6, historyRepository.observeAll().first().size) + assertEquals(2, favouritesRepository.observeCategories().first().size) + assertEquals(15, favouritesRepository.getAllManga().size) + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e98635..4c8c5f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ diff --git a/app/src/main/assets/font/manrope_variable.ttf b/app/src/main/assets/font/manrope_variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..21c45b9a419725ecbb039fb422b40af23d9826b4 GIT binary patch literal 158292 zcmcG%4M0>?-amfNy?18Nd_m+*CFD&YMMd6KL`2U{6_X`EVB9yO~wQTdChj$7rb}zo4KX>7(a^LN59RLbJg7C|ox%mFI zYia*aV1v8_;l{FxIkU<_O`RVChIlU|Rp0~f7duQ5*nQvOeMH5g<&}TxFdzcorwhV; zM;0!bJ*(_+pX-9q5Gx3#&lkWHC=qC&m;ss%4s9$JeJAW_9 zDR;n;mKd;9K**dDUACx{V5AsRy516&DHVMII&XZY<7 z%*2Euu~U&MrmAw$CGn;;#8lrq9wP?+9N(xKtx{zZ7EhqvrGVcxLZ2S!HYCwJJqG1Z3E1j-d5^`Qvvt_+ z&iF+|Muvx(%>m(&NlEc>z>TypDjq0?i)~QL%VP%hOYYGtShi$$>Xp?$a!h|b(ik$eUfESgGcZxtS9Rq#bldbp*H#PsH_iw4wc^&SOH$tn*tJA^$p9i(o z0Qkd%}h z&_(p~C4ZG1;A+eif0#HZ>!C4eY4e6BMD$D!hzrTyAN3Lnm;Vk|AXspJVoj^X6P-!Wxw5f@SQxou$Fg0;NdisF= zBS!SMLqa})&+(%@qm1aUJ!6X(k~j{CLmcGRJ`?z&t*YiLaA!&dNEg8+IUMmXf2k48n>D1mA(y|5mw2473*>O0gZKF zHimXsQm_ElPYTs}YKaPlWWn3e2+rNeZ~*aX{Y~x|tS54p{C0))toZm# zB~y1zRXefE`J3``ADg7cu}@6(MF+~qZK>!P8?xkuvdY({E2B0R7e6=6x>3BGyD=|! zRi^bSpL&5K`{I9=e`R}awa)G*p4=)9VpKAjIqVFY9|e4L(`y4#nZtwPBKpOL4T_QE9A!xI zh}ek(Jz}V)2aWYjiR>K}(3P=I_6Fn)8#rNrO=fN6nHHS10log(@Z=FAOQ{yW7;^J! zo8Dk3Y6Ak%m6e`>NAht>~Phn zJ)c%o{OP%@YPCr8&)EFh#EGwM4k?XQPq7?ReMkS_sed^4j(X*H{vDN00WUN2(WA`# zGTiuGFZcyt_=S^zB?&$PeE%dknC^}c`@}hd^c4%OM@k*RvcpU5;ff84*x(T`Y*X_| zmn>PF&OWVDDrwfy3c+=nWq79B3@MXoU@X_^mqqdq@K5lQQX}|**(>qFaJY3&4p#T8 zTvk#e{0q&?9a_+=)u9zTzyn&{C7YFIAHiXQ5BEU^j(zAP+XH7Y28DW0mMp{0-S?vz zi6c5SM)Yp$N7H>S7pW1kJK=-K+MW;kT!12X$KD=sh}!MDG-7u2tF8yHG&)`POA)(c zzdDu|DH5~qRg2Zzf-6Ppj(r~2an+)9`(BM0UDM5Bh|$f+weSd?&w-r7IEOL$e!bfk z0l9Uiy#Os);4^CxldHu#B2YukHXwsYE(1j8E@U8h3QZgd-k@XFN@VW930HAB>_yWt zFIi->fv2JWUM}>%g(x@L%h1d4b)*xpH;L?LBso$$s}sEt&aby;USo|V$c|42d{_3@ zLhSi`Qf3fYO0zMeeI4*e#BR@JXHh$HG_Pf}Zgf1)MXavFL9E`wPjm;p4t-zJqIA7G z^DTaA?5xPT<;we|rxeh=Laub^yypaJDQLzJW+N^qFu;+-_ zpi*%i4{QGT80hX`hXKqv#F`)~)?*%Qyb{X*F0eJ`n>y-y(~OJv=Y9{s)F zRX=-$%ZK93Fw9rOg+#j;%LDY5DIZ%1;Sry~4w;_Tu`_2l<$AQ(CJcT!T^XkqZ{Ll4L42J%KJv{hYu)X(-}3037m$W8b8S& zwH!wPy~5St(NN-k+-Q0oH2uvY1BVc;Zy!(N1wd@`JV-Q@nB9&KLenkrGU(_5%fys5 zIas7574jFb4)PBcq&ArM&bht?U2`AG8oj1ZU?)ZP^&fa}PSD6TlYEDjCZ}igFP$m< z(&!bCK7K;fq$vXa6EQcqCGCd3^&lGD_pCccmjGB7O)=d8vXt)myx6^(Na?MOkYTz;haDk4UscFsb zj@znq0l1iWuiQ_Fmm6=wU{5K!y-F9L*Csw9!zW;l#4VPd(v0?$Vw>D>`$m5a_RrJI zs9vGx*;%87{5_uz;dxcm;nQ4*_wPWG-ms*Z79eI%0(eu309vDgq6XfF#Qd1nG)?Sc z{fUz!MzR5=>Jy;VSE9AG{Hw|ehl|Q}PSN{Ai zhEKh%T#vRK8Y8#N#V0L0!H(TFj2yV$ zOqUG2kn6JR)06evV2Dq&;sk7P-8citP~F4j5QrRZH(zsjSX5#FC9^1-6$O!W1DSID zm)b`-hZFP z95J$&1XJR9%WUWf!NmNtIqGl?)gg818nQnWs&(k*U?VSK;E=^}u+Lt)q1-u2bdAOU zhw#t?A1qz8e1xp=0{IByMWkcQ>V_MdTc#{UE8^&*VZ#?2TW1bO$;f8_FLk{s8+O=U zyeJT91uvaTZ`q{`-6{;n%2>PPx_S@1qAW=(IOz#gMj=cad1d5NbQvPf(RgK_hxV*Z z&eQN#aO|MVvqcW3tq8Zs%V3R4<7uRhduAJ~QPV$<8JboIiPs77n6harQLb`123?4E zH6P;g%vWt|v)q<+AKMmx*SZNQZNWhOq~_lIZR_T;zHMd_ zLwP8}*J}3RFbj4q3FlctD-vmuu4-L2mUtI=EFDjC5!abE;3-f59vPMBpOlO$oT&I# z@ML!b2LVzLuutuq>?VtK-ewtp9|VNLDjHJR7F@Y+HKXXuAvZzEdgvonv|IWV=* zZ0mnG&+1zjK?_}HU0WKdL7U8NG*uYiI&B{3=dzRGv|_^Lp2B#xwB2fF4t9|m&h)5< zZ6D8IezOK&pd=SeB~{Dd~My5F_&(Ixwk2c)4HCRQSyN?r6cAoYcZzY zhic(|s1Z(1X35C`77Mdj$xW_I)q;bMMB4 zpi6SA?vB=7HUBKjVZt=HYe~Ez{DH5>;&ZPu(|O}_AyTe@lhNkPxu$yfb5xGdnz&ZB zLQCS^!UYU2TEzlYuhfY?F|p-)6lG6&Y>b+rh{O9$TsCy*vWflrs~POPsou+b<>`eB zPp|a$vR)8(czKs@%g^6dig(%M&oo#=0LzzVUX(we6?pt0pG4^-0!S&QC@PWQ^)ht| zJ33)p)R^G(?9`+ouS``76mj&H*yw&tOdiyKZMs^(j+*M#o55WN+@E@AO5U#2j2!hQ zTb?;Mc6sXP+Clvj0b17=$V)<%4j_+6X06Ies>@&t(;I*GC?A%Wq3*~KTbBzUQ*L1e zXs4PxUJqd7v;}lC)FQcNcFxur-qvf1I6s@El&QaQ#qqhXt@P^xj*m^U);O_U*5LB_ zF2SYzL9>iqL#8ROA!<+155sVEnY+h8y1;GLz=P&=gvSGIz=P>&7hUT-p`!j`WMsGs zx<^Mop{?lyALRL(<-u2iTak#v;z(N{D%$f8FzMB1?9cyrwx`l_{l&`F?^Gy@v((+?8-}m^{PDVPw)!Yt zDu4U%l2=O#D)U+236C#+KJsQ6w_*4Zc?R?;UZAFU?07b%eHdoY@X#JCZAD^yJr1~L z0avUBv>R7V{o2V;JvG=ajP-&tTCWS!=kBDE5Nr;;mKlk9wneS|3oig_-NeOu6jO{fA80RFJl2V&kXMnwo+SUW^?XEvff) zn00FP#tY@p1IBB^Hkqg{S{T81S7CK7!g^a?ZoRFTaulWE6dpso;EOSno|6!1@j3~n zKYdinZamAX^d?jlcUWtsw}H)DQ2l1>T`zI70jbc33l2+9-BZn)>PzqtBh>)1MZ78O zYx;@sz*wQ-3yf?S+soJ1v)PcGMu&YWUz5j|pvRAE6Ro5fJErJQL_lKqh9{J#zL-D% zi>H()8eRzA_;bBxF(PXN7f37Z~DGr+r?IIAa5s-6k*B@Ej{A47gc<8{`hIo&$Fx$;f%TqRJ>a zrPzz}i|FbhVBeZ+%8VSG69^;c<`P6CrP+W#_CIKH)mBSg@Qqfooq9&Lz$JTbK=}-w zc$P6{_fCC2Ot~?asMhdJGDg33;03KbxR!Aef^w|z+V*!jN_bX_`L#=ckF}gZ>vWkO zH!FbO#~pm05r|5R$My*1w&I;g6u$@<6ke3>=ktJfRH$TDT#{>!ey~rk4jo4^QSOkC zF+kG+)8o3$Ao_GoIav*#mb-;cMkK!577Tc;`dCLa2oJ1nX!Q4>DA+X~q%EGg5K~zx zNo_#mMvY}-OB^tNZJigCJE8%?EiXvf#P8dHgayjy%n6!D!!Cgb>0%!t$sI48_~4u# zYDGI5KW&q?I_XQxDpH{tUzy{Z(MEhj_*~ZA_C!a^a#n5YbDnZOZBcv|DIS|Y)O?G3 ze+XNk9F7x%SHJ+YoQ*51*M<^prBnAcsG0FJP9Znr1VPKixa!>^BK<q-Nr_OLi@7^3vL8p*?C9b&SzopG?_zp_lvF_hKg>tsgIr2dZOiJsDw>0}Xl^phH)@ z91!#%R?_IVr-ueShaS3PDaDSQ#v0OP4Tl!IfE*p#PNQ+juCZ1UXlq{w8W5-1C}^?d zXU~-3&cla8$F52RZE$43L5yRA<8d*-fq)l0xqW*C+*It4o*`Q!N1Ax;)Ji^WqWb?b zX;kA|(z}i4rHsb3VAB94tl`sqVymb<4?W|xQ_U^NNjS7(FGACdrgpVc9G+1-W!GNI zqBwR@t4+U*UTn2fS|465aDZ|+c5n@$vyQ!1JSqx#P2Wft-LU$r`L=ku%P zMJ*j{EoRZ;oTo9xtY6%>a74E-QmY);1>UX zl`RcM?WQ#uvUNPO<=;T8cDhS5z5mo@Tm0|T^+mCRm&QF+mOqzr-|3l zwwoqhI=k-COLGA=1lBnD=6{Ebc>Qg=>6xg@h?nHvvy8MVU9NYsw>d($or%9%@0@dB z?e{5q;=JV!= z)yduK3;@v;?Y?V2?LX+{#P>wqd|qLUae?ASecttJ<}vp~L8p84H@rI87}I|B9E^4# zwx9dKv{Z9XtiQ;Ya*R2o?pMa%y9WLG7*2N1lH40%AGKIAGGtWE{l~A+FWRlyei@bX zvcP*1(kP^=`8;0@lCJ3Xk$3Mha(x;>`zd0hTJAc%Ct+E?b#=Uf;qMc;yv7KV@10_r zH|v#7d_OwhZH9U$I-{)pbSE38??te3mFrZ0b53V3r@i3UQ0`$y(SFiBo}G42 zvX{@em@#Ovo5dHp9x4nH@&p(_RwSK7mV~tyAHVR#=5jTD-W^hK5lGM_CL{)gYGrKk zW?kl?NZLWvv;vK`0jLm_%s5v9HY~sPr-h3@+xg5Va|`6jhaXw}YQdCatE(@hWIV9$ z<=IyDN&i?`Juqu$<*xPfj}_OyGmjOE9iBb2{!DP8Z}qA9``-`m=+!exZ9Kp}mC_q8 z1V6>Zmya@G>(lE0oeJ9ZZuZGZZ!%Brg9>FY9-<)w4p7RBhmIrElPDdV3itJ@UP z>8r1ntO_bwH~h&BeRBG%n_hobTtSAe)fmKLJnza^pByO12~#+&7gshz62|Df8q!*t$y zvhjG*n!h~p#8=giTzm3~zpSBLFXa+%gYWlqjz=YNj?>w1V4`Tzc5dTbs&Kad`Z7sc zcK!L;$7@3K`oCTE-epNFK2=-!=Ait{>rV%7W$cyLn6PEUs{F9I52<(my7St~CG#R` zHzGgGVIW_5Kcq|4xrf5jTb?2DEt77cnL6?$qXF|eU`DhCQ!m!@40gMocXkiCK`Rd> zdetzUr*!U!Cwko$-g!pmAyzvH2&#~!+N`oNv_ zqO$m-En7ZXD2j_OZ+_y#1@d}UnxO8Z^>EmxDbI8YYs|mL)WRa>%Oxg#j=}5JXx~O3 zt=gp7ACp&d$uS-(dE}bP!=g5@&LqP*avhcX@^QgQYOclv^78EP*DF`vcy`ZU9xjq+ zo?N~5%*>f*)^7MHbyUpeH|D9?IJjzC@MgwdJHgoFb?X1U9rWB6OCP%S?6cP%TD>y5 zcwHuU$~<y0ry0 zV>>#HwUWLB<0x5%|9&0scy6o>xIJ#44QmVU zc;ZXLEe-QiO|E?Q)@Hu7$Z@p+&)qtnEjOvR3F($w)NTAduhTH5jn=E_rssX?Z6N4r zogmNIsWuVkG7+VX9nkH1oICiOKF)nZ?>W7^KCvwl!{FWqYO33y+k5#qPxhvd7yo7) z9%&s1t5BoG8tooCC=b*IK4ehlr!}Gd%#%F2uZ9hI3vpBzJ*cV808@N zpZ_GI>VsE)V0xK^!`CfB8#pNq8a+H9+^4H=dfsP%_-TM^x zONfqB^IPSu8rm};d`9xn7U(`xZMSBV2&(}@_C zKk=AJAPI!ow1w10V4Zsf_$LmJ@~f+zHBGrF7FdtU??w+#=@D>$7cpo0g1ZIs$ts&p zh|(SS6<^FJ6QC0~)=b|MgP8($U_c*mV@NX-U4_Vp{+cy5{+-27zL@2e+cEIkm1o>c zFI4ftwQ&_?3$DJze8uk?f$mhNnXXk=_*sl( zFiaoWG)(QKC)T z9Ggz_uQ^s@uCGeR-{aeqcAJ#M&i9sZI8`*S{NXP=I_e%hy^@bf;204nz0`$WD&FP<_cJ`-f zFK`LbBT@*MqUx^t~Fw!c25#BPzW!>DCGw9WgkDcL!;{S)&S-xcP+{ z-zbW@V*C_SaKrc0mnB+iYKUjl_6O(+-yh)AmLK(QYkos_&~{y%BMstbMoL!zAA2OA zvs7iTpW7L<#(G)Yyv6|QGQEupGbC!rz+R>a?ufQ3`w-5`FpXx8z zysUnQ8Wvo_yR&%mCs=vpPBO~yzxc+qEqnF&(d(44rs9_ume&`FxR#=kobUQkYw{=6 zj2=7VrFk=7C=v5TDgDvWX-_=(@X1+IUR-STK}sy|dgDPU>G5&7PtFXPdvJQ*!76K@ zc#YG}{l1*%$?p?JE?s6zU&$H)3H@?f;rAPOzi$0L^-R_7E_-K(AgUIAUrqr8oA3-c z*FF1w4oA!Gxdx@@^V|?*x~`}3h%W3)7Il6_Hp|d%ID_$U8QSkvb`8~bh^-sA_L|nr z)4iNlnwPd5N)6ba3U*xLA_jPxW4OW3wKpRk?dCk{f>TGL2~Rq^lkBJ@NX-Hqs(0UQmd+;B4=+KLN}qwd75 zbAyg3ZJi+wdEoZ*glB-=@WfB&39o@{9TU5p??A+0nr!&4-j);UW}VkHqeZsY4Ne1f zoLXjh+kl|k{BDey&LAc|&S=$o+LGCIgM5sRyyt9g8whck%Qfr*<3G_7rtR-n>Osog4V2&dJ$UkZD;Y$73AwiG$?tx$IlT&H2nlcf4W$>wK)BM@Gqu zoR1IGeDxrVO>|x;zfn8gj5HgaMx&oxYM*y?;kyx$$%CA*uvWVtzT_93aA^41GtT;s zHa@OEhYcQv3H*H7}81Qlx zHw@H{lJE%G#0zziRe}F3Mx0J(diHfX)6>P0Kyw5Wa?oEq*mF~Ay}xz7|2{U-wQEYn zQwMr(9I}r;<=OIW{yYZx9vaJ$G50J5kJ{!=G;=*^muC!JDihGw){`yAv5pfid@QYp z;fPG;^wg;#ax9+*=_mg=Py)sFYqaUZsx3~auc^b`}~oS zbIv@x;=;on3ObkUo;h_(?$9S{54}Ei_FHR%M%GUC&YYbzc>d^NV^aI}%bhiL@`kY! zwodaNyJRSfpOKo7Jv6lMq{WkGZN>I{%0mDr$ZZh6N6+cpPS05Jj6vHm8w_E$2RoCO z=JyR8uLDP{QBj3u+c~G7@YfCo>KEF!3)5eW+#7I3eXu6uG3~|GZNYHcc|6rc@o;-6 zQPW)A5Z$aN_a*~vZrlzT=&6qOU~c+_)(D9IqR#)ccJtpp8`=(R(~R|k_TZ})*)PV@ z8IZSRvq?YO7qcL{GdXR8A0|lpshqTRf*+4<9Lw2lYIOgJDf15$%{#yPkwYVErp|i0 z=Y1WDgZmxd@X*oei%T=>ADsK)lR=%l74MGu&n+lMWM3kJLw0k zAM>?%+M`NKfx6Zv)0$u(t<3%~7T}xnjSX>FXmV;Hm{c7k&3moXG!Z9;}P|X zH5m^*)9k`U?`j!@=mvz{nheJuu^++d=Lrt&uyZ`w)Fsr(4)rna;Gc6mP6qm_;bB`F z@<6ZdMO~7tWf0v!)feDp9hOG3zF)_k+$e{^bAT?u{jBW|b_cDV?*OKlmhO@^bi|`9 zO_cX@0ZE-wS?LB0F9EReuPyq*t1w$?qZ*EEh8rLzDj?8hgq#KG3g&?BWz-^F?TV&U z=fs{H;I4`fI4<%F$mbC*d{x1n1bL>U`HW6i>=l|E59xG+hX>IxffxMF78P~s&hEzb@GUdYw$xADg#Ud|kHqlAn(qGVm@Rws~r(@BR*1MXHMxj={o&@eh$7HAMV7bwkWx?W?9FyQR$sH=<4tY zgP+A@l$c^N_h45blV9m?ZMDV+&Xy**_|+(^KQDA(mRnoUVR*lY^(QTKGw z+>7VeTzHrtF#z)1nmcs3@#uSywDG694dX49)w+OYJwUZ@Za|q(2u0&Skkp{Z3bCH3 za$Ona3hLsP+tuBm;HGj)_hHfx<^BOmMxhHuH#{bXBJo}4F1VtWxY$_k z21EITyRGBMn`!ddD~B=cF2o+b8$!0a8+jYlEJ!=RQ!`~P7#+15UC3sz;v%)OSWaoo zkj`s$et6Dw1kZIcp3_fd#nZtom|j8()H8+ZH|jTqXjdp5q2Ifgq@#`4s?N_zH64LY zx;ZD-MK5e3^v}5~TRw;5W7L5;o#x8xz+555vb6xmxmP{1p;$xfSF26qxh&#|PAiIC zcrfF#YyGs77G3i&#uNSB;i_Hr_%~V|apEKoBru=icN|FHLLR7Z)K38A=ftwhYJLT) zWGgDve0FxeTC3K~7sEtVM8Ojm4?(TPTAQF7|4>V94$dI4m+1!d!a!xIuf#{lBn=fk_nC3pNqYu=d+4Z4C zrB2-(*TmJ_lZ99CaXE2Z=={;nn4{VCWC+QrE0&{0n|6KGXaS~97w1K1`<^Inb?#+Z zWo2^@w`x4|1%h>IpAVTfx3^r=>Pk6ZF@|=)?q54fe4O{q!;LX9(yM@En>YnaV}uzsGory@vA=ixU|8!VmW_nLhTo=L)Y;zhK?VtJ7?5^=7~XKZ{&OVnsGiKL1>o| z9X*So!{OxFL^_87-(g&#lV>T@gY!&2QTmQdkDi}3IM_UNLVR|Y$-WO3jjIUg=ry5h z?*-%Yr*!^e$I z^z9#N>DsYt=T3dY`^144$dv)VseJ7Pxph?PxgX(7D~6}9$jzhI3Hi$8iTRTzqj7ev zqO2R8y>8Ovb=i{_6;EHVuz1D-@E*Qc-eaP=Zd&8f>h^O@_1LdNId<@yZg^|HPcFjo zHaRKV+KHWSzERHlLyHUL6xFx+PC3Q#LOH;vdeKS_A9QJJ98x3Tn{?>mb=WC>3$?=h zMj4Ku)QEar6D(Q7FPtw|@TdM8w!oh>4mIX=$T+txo$jfb6pDgDT+~)4#VU<-Uw!=@ zx}}D4*4`$x1I?KEt{|x=`6MTY(*A_RP`~hQ;DOm9VwJ`6_Cx);nSFdZdPxOk%H7?H zTxiYnGIb5?96ofQ^61;oCO*r0d~>YflzQ&=ZI&hf^7<_Gzy4>#nc8q}6QGr71Fx|r ztIhzG63QV@!#QDje-34>(oar)5?Oh;dQ z>@r^fP4L?^c7YZeW2pwa8^)lk*4pJ_r6N`?x7Ld1ms_(HYxZ*Sws^vtXZ%{d#oO91 z?~m>bi-av|F`;eK6)umD3{Q;HwW8Tlc*2vXJ#l2o@hj_t3;O)!aP{d~zkhRwPgTtP zXD3X3p}g+5nT44RudKTC@QN8#n;yRLTyXxDsSzumoVaJf^R*VN6Pm_vm@;Ei_T!I* z?K)O;ysG-msNN^Gto?0;&O5r#Ud|LIn_>jvf{+e0b^Wae4?E~TtQe zqBQPM*iDCY5Q@2fHg#+$F?DLVV~%jj0crM85B=lgGp}st{ZkQMbC0_)1ljjPr(O8c~N-i8tD29fUtfQN5$d^sD z-;8T-l23PNdACrDNIDypSe+ZwGc=;BRH0b2?(+`n<(IN<`MADMpS;ChVN*b8&g)Hr zdg6oKr;5T9LoZD~aXfAB<-Pphh}3Z0H50!L57XV5lZ8Fg(~As zIs7gtoqqThv|DEDCz|&Y#NPvdes_~7)bM_F-^A~jBJHK1v_&f%5`e7`J5`;4Cp3nez zl_c$=Z9^IV0yT{qfMD+e>_;ZyUXq+&*;Z$E7}vJxTY8PvxAC z`#7KASL7n#5~TWjTxIbDOt+Y^4? zCP9N|!w-I1+>5)%q{|B3K&ByBa$7Kb0*z_f{iyuTQnw?T(#mjVz2Igv)AZXk0CEXq z)sToJq{~AHe$)4OR|@#rn2IsQ)0kR+3hNghhtwR!EfN4n`q6a5D-w8wvuf!}z!up8 zpuoo$%Ui(1f^~oBFUg9=L*l)MFq`{b5|SAv&b*&2xBXhd=5 zteIEVDsQo#dquJQw~uyt{^rBKQD3|DnR@I!j$1uY21UR%o!)DE$t+%YpUJ-$nr?}g z`S;`Sq=;Y54kemnOayPcqede>#D^~g@?4fB8SXJ&46@$3Ne0Qf*%>5_;=5s!>bG~) zN>--XWaCv+J-UVLn!Yh#GM$6$0_eSE@S{H|cWZrRG6&b9L1yJc*#$E$*I zN1-*f^^1b(&e9T$k7Q?EP=yB1L{TIS?f6MGbyDu`Rm4+bry?~(4J#5mDR=7Y;o(Yf zUC=dCJ=rhr3%WowvN9Wu#hhlu4fwtzcufl9(@2{dt`AAY9>#cM6klG^jdLlOP~voaF4Z?CA1?&KdI^zuK) zPoCK+?b@cNzI?dyZ_n4Ao?i0i+O==YC_cR|cpdBVheue@*J z2gP4|e$IpI|9Ag^+qKG)41Jd7GHHvy@fWU@ zrc(xKjTX@+yr<~!u8!?1%`RCO9Mt21UQsDYb0_!7i&ukKV|LH%>;=<8vea#Co0QQb zEj@GCkV5qsTPCOY^w={yep*DBzqx1R%5e)f2i|8=%x3S3Q3Xpo%4#Y5$;-=k?ue{8 ze!glc(A35_3CTLUd5L(%x}Ij0jY0YtBNb!5?mREyPtm-DKWR+RdCY|VzJ!i6&r8H= zg?CwhN9!}S$y?3s=KzNW6 zUp~2#4QIm}gS7dGwC!K&_5~9r_ar+e-Qc?Nv7tM3Rtj|I&!AVUA?<52&XDO(Ubwm1 z&;5B$#!6(X2WW_~+T5kJ<0hoNltJS28Iu=y;d(wJt086Vy27rPVe9JcGSwHe&N9rH z;b@KD=D)Z+;UlE&m``Kog}Q)byBdzja6}}U$FiIH^9fCqUc$i^|8wN1)c4mHZyDde zd~&pZ{XV5CW9-@q^Un^+829Pp75k^nnbv81`JmF{i-UJ=iLR_3x+Eu~C=sW+#TKV$ zm&Fx~O_?!c+~#Rzo5G^a1#2>9Zl7rA*h$ccne8l^wD3urbW*Wa=VDuCu5_@G(#6Hn zL5iVZJSQO^KF?`>I-*6i+uU9q;t=aLcembQmSgm-=OCN=ra6vXZ&8@!H21;Yr97La z*H=ITe3adc2BBc{rd>J3`itYfU(j*|w&P|V)ZCJpxFOj$H!O-^M>qW_T?TB-oo&{_ z=RtfLR60E9zQFMA;u*zyNA5GwyKiy9sIajaJ6JVKKo0%ZQ0sqxmz#HgSB!8P9r6kE z)rsi9)I*!)VeBek=h(RdMO|8~E`|9w#!6Sv8mHOOFPiE+_zrEG<6&)11h20s)*Q4_ zyRe_`USdC~UHF&^Tg1K_z0OwSA?bnrd`gN`thH!Kn38v(c6vQ7+97wM*)*MZ2^;<$ z#{;@89nktQZoFj|)Me6Ab(tdF!sJ))N6%BEpKP=g`oMRD~Ur!wdd?_Uzx3?$Wu&LCW9Gp8b14 z_r@&Z8^&A8^Mn4;oNv$oX*2kSUFhlNa5{B}Z#50zP9hm70<*g>7a#azZ(pUO-^;rS z4yT~Rcx&G$A5-dX+?YN5(Z~8liI5aKhVDoono8ttnm*X(E12`hsf~GxxJJ1_Y7|6b zy`w*mQ9Vg}-f=#~zEs%f3Xa3kpOMibGx(&oMzd$zOjrlL1J?LXKg%Qp}H0&f|&frx~51&6G zDlbu8&Q44X9aT7WN@|6AlZC(n#&-05`pGG^89e zee?*H&xy1ImNQTUAgAiS@kAbs%RPOtucjZ(VSp7vr;yq_m3~Sxb~F<=C8Te-+p>#% zKyB&pUY&Y``ifQG#pFaT-@U#(dP;xwgs8^p9!=gdDPrVQV)LzAqe91IZDUJWmO56l zbbos5hCKltOm==wHg)J;zFyHuL4J8tiLGI zXU%93_t_bH9K-?EtB`p!aPGT!y)|qw%)yssvbSaIK)oQOLrl>1Q5d(s#kk$uZrtW` zLT#+(hfVwV(IzxjemyIH(3_QSUG8N4P)xe-qjObn_sT$Jc3x{71Xm=1}>*t*q=b=D5ExKRl2Wimy71lEJ%M$r{ zB6>{~=8JTWMi}n{h7B6=ADvx;KEK7Eln437v=y}YBSPo1TpTY=W@|@8l22kn^F9@+ zjqtxr! z#ScB5K0iG=FM-vG1*%e^o-7l8R&Q3Oj+i-fQsnr7?2LE7Gi&l|@_p_z!ST@vJ000I zAKyjOix&HKKr)|XC(u5AQ$5u}+4k^vGxmyrbi`ehq1We*Fg}>PMOr0Lai)f#cW2Q-9kK8>qu6oz$ethVP5%~oW{>G1MjqP8eHs7ywmDoo1@~EJ0JH- zO5-tRa=o31ieqnk+}EV*2(L8`*6V&qTHSEVAlDHdlqM(Xd3Nn2;HX3`??}0LOtyWo zcoWY)ed9r2FR!j$drW^~+Ok)sKk;bbxRYxue*eV$`l31S)UMq>a$RBRQ@wn>eP%zI z7d&QNe#nG=;GtHtf;CxFri_?p z-B>VW*vtew8_r~5vTWEaeNN}0@wntB*8}7!O=jqU>sa1QvHUfKUDIQEe?|4zV)<{S z+Qv0vPkv%dPwTh%3wFf&uabwLe+!#~`dUqXycUCx>F}|Tm{yzd@2eqI9AX`J)gta{ zNa-A;09tJtLF?BfQr8u^RG;vC%Q{FZQD|s%U78Di(A?k(_cK3p{?ORA1H2f`M!*B5 z*zyJR9O6{?+(Z018h3FP3H+04yr=VJ@ZPHN8P6jw!t<}`JS~xH;7d%HpTZx{fiFx9 zm)%k$)!$U=RC7ymkN|PRK!5=~mGF}omvFA2)r=X?1)zz~O3bA;*vK4c&?EGTT)aO9 z(J-Kbr4$CNy2T6nHvyFB(7!R&0;9WQTO5RAlCeX~TJ3xg4)FZN?kF7b2Wy~u;<{Aa z_@Vt#IO2EKNDXh|FXAQe3*H10=lxJ`}6f^@qIzPZKthecMLO+np65IP+4rdt}@cAS0ljg1~ z8KYz@&X6J?;tK0#*&9Bz+`0#F{PV;b=5dDsx|2 z642SAbnY?ur&knK_4<>;%La))YC-Cv4N3A~$DjUA-bxGaK zR)A*0D;xQ7z+H(20$f=Vp(_lXF$$=Z7mpyF@$>cZ5AX|3WkYrx-oqj;U1AY?4)0(o zYN|N-z+#hWLz8gqW$VTzhYHkGWhtBV#m8*a+i$CHef)*`>QY7NkE8#eKgkB2KB<0s z;zjjeul46TW7tv{)luM%_&@365PkkV_=6Q_;kNz_;#tl+d}rFqzZ)hXdBR)SeaHU5 zze^Xsa{4X{{;&aSCgISBllpi0dY^gdvyiuYfsNI_o1uS~h3|ecox{4&kHS&=b~yj; z8WiU(teqOV3EyO#Ez%s{l2zMk3?%AR?t(Vjjqk>aYjZ<~3$Z=iCr*9|wL*x4;W(S~ zjJW*-nqpk^?6|2IcN;AFY**#Ehr9Xa=K6M9`OZrFdm*r$R(kP8TnSNf`jI4t3Q2Y> zY4w>|4i`gE{Ea=uGV@cM>kS8-KckRDJin3MDT0>o35Zw}5E zBU}Vka(%WIOFeR|hh^Gps}V}&jun(}%rt+0v@7B($T7P<)aaPqM zLxYy@UK}|tvGBFB<<1d7>wnl+|G(?Jx@`DyU;U38lqHw9{PDk+ zeE8Vr4;M@8yuE^lE-n}}A-S`coMSR|9u}LFJ1l|88RWAdW9cwriA?sq^o2!oG_Uhxm<4lMXo=DiF3pZzv%%+ych<+Odjzu+<~zq)D21p$ zezdZ7P~nNK^V70s4d|S|VNP!rxqI)pZ5KBL1_ZFsxj5$VyQ`~DZ5~~*BCg|o8IP75 ze$*E4aNd3+AA=9EUpKPLdQ*JwyuMZ=H8h&pXb-DveATG+r8V-~^-Z_MY(D>{oHPlJ zT&1>sFTg+94?7Lmh`B*sI!m;H&p#t>_QcrP{a9vd?-3)Cy}VG>GobI#;URtdnmhXB z-DfiEG~(_uib3{6f52}j(K+akA7W2j=0vG(a?bt%nD)`n`G|q}qvKMO!&4_GMRxFF zn~Kzf>VZPGNnTqzJt4=db8*%Dz#hm#o^Pb33En5!k%%|isg1_YGP2!79&5ph>hz6e zN-kz@tKLk>MH1N9RF-@9(($s=(izrD=m~rxk5xsARW<#PQ-E(A*Y)s_2xv#LrX!S^ zu-G=*2l&VPg>hYxL-7UImA@uG{;c{Z_5U_il`dQU-m2Gs9$7f*?WgBFSNQt=0hK;G zst%Xn@M*Eng6NFW*e>~N%X(dZcl#T?`@Obe#i5C>JQMc#W5d@Luixb*DrQqs&5}0` z$>xq3>&v-a>T^Nx75*N0lAovi5proG_gxlR;2EcxIuEZQ!*`c&J$^%CW(5XlWmqTUlb| z*84vAc*wFpKUeWuO?Y1abF0_AF^#e0!@Io_$|Dyn@y%a1tEbuP#1{`ckZy4Z>}nPt0rV>6uYMG zjd}R7RX2C8J2QRynRT^iic8L{34WsS#hw4X-A5Lcpj7q$z4~I~6UxJ%ZLRy$3dSlw zfBebMmXl24nr`#_cM#F4+Y`BzP)C~*XK|a-65}onYYk$gT1V5O{%KBmi_=B1yq(?~ zQ;bT|`Um$-Qs%w43F!)5=Swf`7E`UKUihzS%;30h)N9g+oq+J$BJoc z->vyGpPj;F^W6qE<=L6}TiNHh94_<(tH!!7hI_W3 zv(n~SLzGb8c$hs?ju)K06!XRlnU9stTJ!)@q887dvuVVrohxHLJTv9^+Ul3Ohjf3b zDrEIvcYniD%5#g=|NC9_g~ii$mV2>a^_%kDB}*?tt$UPo z7-t@npTv4mpkOaqCr`;wJZR}HzrzZWdygsqtX0t?9^x?SIgAiH{c(nZIWOe^UHIi} zZGqE0Fw;?5+C-l4Q;i-uOhinDDDYEtJExQD7wGh(^x?txs-9@Ne{X9phen6@%k*a=P zC-4%Ex6ra2nUWU7HpAI5|w*|bCgPsmkuf)-w6xU`zs#*bX>-e zv-2mc9h*_5?5p>Wo?PC4{FdVN@28F$`Om`4An4JX3}k;|Jh`oYhKZ%a)q`!VT1DrOCpgFjAC@ z2MSVWy;M5q@Jyxh<5iD*ydr$;FxgrXD3v#^K!xJ?U9(GHnl<~Sl2sp9R(-rOfBi!# zl67yY!f~OP_Y`mm*Zg)e4q>p})@F%f0X)2oN|LqW8@BV+s^Hvyzo}aK{KP5y=l3h? z7blkXw2D%&^>gtBChuJIL2dA;4by{GR!h?KBg-B-IxWyQOB|b~6j*P^^_lkf{hO~Y z<8VQr91OT!z=P04WV`hOP8_=&Y#qgJ7pval4CUA{g~cgy&%57Z?LJLWKhfG^toB7) zJ`-~|y#d850h6@wXd-^8A;-FlF(XibZGUEqN`aAV&Pf8k`mrlJVYy z>;6((`VVXJb&AqZZU(dK3#LZ zT$%99{DseqAOFn4`Oi!c@9fyTdHeRwn|F|HLbl+q+*Rx&BS&+AeDYiP40Zi-CU(D^ z6od;xIz-Xb#1=vzS4{O7jY5Pw_=ui8484lQv!WQv=X8hBwj-Vq#jpbUz8>Ef@%Q1W z^nM4v&*JYRV(GmBUlb9^;QbHaZzH~!#7Le!qR-BtO$3J#m1f_jt2oexb6wMS;uUEZ zc35HtY`w+%BD-HMt;c6+w$ES(;xTDAJ{xZPEU)Q%v4%gFZu^X$yU0JooI~%g49}hA zpJmuSt84mJyv^Ze+CGDhh`Ic^EZb+5c&q&{K^!F9hNzFIXo}Six zZ^F_Jz6&|K{O4wlrmCa`Ma+bsOGr-zWyLH3~=Qe*`D}5Ni$Lgmn1O-2;{BL=^5Wo zR+k2f5!SO}1zXX-AaQU>^5B_Cd9`E5Kbo63X>^pV?nuoe+Y-IR8sLI;G*C_jST^5i z=NfghVPQ-1y5|PY8<|!V8&{kfJESkG2~@j?vU*f5-#-nznnbUGQ&R&+juBbn%;CdJ z5(Cp&MOtp2dM?R7^~qTU8^##4T?9;Lzlf5DE@}abEmp6v-2Ae#eDRY!vDUhVbyDx- z!6>x0UgBA_h34w`I@E}{@QWFhicfC$Rb$iU3>{WJG_5o^rzh4$Ga~&%`mD;HxM{+q zE%~C?kn#-Qu|=u;k->u^*wx$+HD^Hbu)IzA6Sw4Z9TCOb&=Kwv?R3QE6a75-#QAmk z6Sq#8u(4l8@3Nu8D~5(dZkaUc@w`cOlYr9D@?qMmDCTc1VChqz%+F3s7&Lfz#jqbD zbBcfhP|Qo6I}GRzos&9jZYq}>E>r~{%r>9MepZW^s-7$|<>loyUM8O?isC8Yd_Put z6FI+V((Y#qRJ3JFUABDu)qS9YKg?J+X4cI7!pzF?<0~_#P8Lf_$E;I|7S1m&Ubwg@ zzoaBTucRbT6f>(P&fIi=f`3)sxJ5&TEE+els#{#>V>2dJW#*L?O`kn`dQlnsBtJJd zf71BzlORg+L%NsJBV2_jiu#edZ zH**aIdA))k;biZdd~udBj#gr7T#Y5Uy+ByOCTf; zU08`YnhH7~ewy!(LJt2F)matb{IP_DMB2N8@jWW}xt?a0Q5l*XVy@}cw~xika_V-! zp@Cir8hqgtQ!qwEi4(C-aSG!R{*^j8;1(w4m(iI4^vwm(034+6gi6ezP#TK;4HH=f z{*}au;uYX1(pmz`GLgQbN|3OmiO6kS8>H0KnCej$hCXhqyRiuo>%L+E+NPun7tj=K zG2Tgah*|hNVuYS;#f+Xz5!nR_^pHP>$HymHA_zqCjjs4SfUYQlN}ZB?y7!ux9zC#o z`2FeqAI;6JiH-snP)2=fQXBCHF}-fz^EiOaYcC%f16@V^1b4 zOzx>R)@RipkVnTj1@opYKMY^TBy`36c@7K zm*GW>QFHv+nKJ&mtZ+HoOfRSW)tqwvT3t}3R#q2?%gWTsa`p*U$rRMFTh#eBqAnc__GLnFQ;K5Cznj?Y5TkCsb145k`?0MI>6zwGSy$;u*Dh{D22#o*kKWuU5MrRpv4>Ku5t z2{=r|`Uh_nG2KdpL$KhQ8!; z-qyp%-{ig(aqL}<-}UlXcGGkgI$15&bWksl4C-Y(hkMqe&@S4oXr-hxA^==CxyNJD@b zn})zr|4R&J$70p>YHch#Zu|v!b?75U`Ve^3NPuSGLF1GZ%*UxW2?w?f?|0#Srue3O zy!koco5F5^jxccp@Rh_3Xvu8~Ib`x=U%+;|stN(xQmje0vd!@Xc#e!y9dEW?Z3I9s9j>R_Z0ZsVq zUk&(>a>oPAOH3Vw;lemrbMvk+jBHftO?fhX36e%WOCsgbr ztK!?(Hun~r`nTIG_qncJ_t(r`=NozdhTObWnIo$vj9DKVnOiU;v2d`3b=s+Zb@8%# zrT!!v_{N-%aw1B;STcN0xl&YGR(SP2Hs$JNHszx~swaQ*#_4I3yoP6vte!BbI&;{F z$W7{viL(*n`dnoF4xLj!e)W|4hc^btXQicy{RPd1i{}4DreBIi&BRp|dAtj*J@AGYyNS9mS$9oz4$A=BML~8W?_eY0S z=VufT2~P4+gOf-6{sn%E zwv3#xA#V(pv$sAsYtf0B=Cv=RmQP@XI}gq+KfZ8tWsd&U!ecWkUSHC&zuv_4QjR$ z6jmb^jfCEy%G4Ge(p)sk2phGc;W&J$?=cMbkDz+3X5$eDsTY6q85C)7dxwQYEE`aSBZlpmJo)J%BZdd|44O13C~TNtbV6+Zs?5=AV+O@X zcOM)XoRH%m6m*8#AF%5WwL5iUzC6Hm2ekJ_b$&9JzeBX>ly;Oz&J-TwK&)^-)CW*T z4-qo^`t#MRKVQ4%v(>9VTQm2y^77Z_R-7m=KT&bua^$GKvC)P3d6n7!H83uywpPry zJ-GJs)e4@mJug-~kQEi3<8$)h!S7;2!UoiHJ+Bl`noFVQow%OU?xgOtU5p{0qb+ZS zOO<0;U)Fc5n5Q;oscN3uMg2)!tlojcw|*xE;u61fHHLj^y&&$v(q=hyK2`id>I9!b zbhn6*ooHqmhi1(_JhSxBtXYRjp^@X(j@4epA4(3FYColiOBCDZ_?C|``j{z-TCNGB z4D$jV`gnAT#v>%?@gHot$xHtZQ3#DzG{3}+@0OGL7jmc|n>}iWU^2YvZN`b;4}JhZWNQN86PER8_41 znYkOgfQX2Q2#Sb^7q~1U;=Pwe1Y{Qwk$sm<7DY4zG&S>?nVI>_XJ%$TGoR1Q%*=e| zGc%u+nU$HDnVFf-%*^!i|IIn)!UdPq$DiwXwmI`H^UXKkeDlp5S^4`6CB z=JyWESoZd|rZ@aj#=h5tFu;N%I{$EEm@!q`>gUH_v=Aqo#at(_^^D;VIF- zt;HTN1RUy(*pvRy*CPeUUE1rA;Jzvu5=Any#~2>P@6pqjmn^xwn|(!wduA6PfbXA zIzO&F!7XR_#>Sdg^UC(ju`OGh88?4oZp-jY_x#86n%^jd#fm0{{a5y2w>KBT-vd7v z>QUk*beuLbC_eR=+O{8z0 zlRa~DDxA+)7V>e;;l~$zQ2qVqpZ=Vdy`j4H#Yua%)V(x``LN1A{8y(bv}wFXJyqlm+|EKFFNt~S(^OjkRh?Iu8h5G_~gI`hR@bBI?yd<$PjFe zM3-_zpcjG66~$U$WG#+bxu;j$HzN#+^!y5$+bs=CyM@WLN7CdogpHW8(2u|lmb(Lg zFXDsQtPIHx3)Jv_F&Oa?FdswnxPp%k+q&4BYeDuR)r@%se!rdANZH_sOn&QC_*u2 zk?VQBFFp5D2G}|BfG2Fr!@vahq5KoKc1YAWZTOlokG6Vd1+RKK@mT69Hvd*LPv{n2 z?yYd}e)ra!Ck@~JE1$6_(t zW`cp9Z?j^=;8p_m4^Sfjm%}btnv$}#fWx+L0PKIx=xuf)O}?1-n6O*$Ur+~JzQRg0 zHz8yK^XKDy%jOMC@-xh%Z6k&!)sG%spEP`gVGeCG9AJmrZdLOQ zMrj-Kx^+Ua*353k_R6PD`dV6~ne{58cHOS^ge)*_}AmhFh`2K}<49{BVYb}JFN zk4{|JWgmT##~>^`!*~R}Iy@SE9Rn~$-9+~Z)_Il)ep#CQ4@0=g9a^0xfxq<}D;410 z*e;;MN^d7M?{yHiYq&kChTS>;i^)CPcrUZN(Dw((JHP__%Sz)LEP%a~N2oyS@(OgzzWgL@v`NH^pe z2B)#Kw4G_RRu*<^Ft(xGe`~d1m!SRxzae8eC>H)gDwo^JR|{SZ(208ExOy;WLk?Y+ zM%Ni~(%1&^XvpFDMV}hSic{4j`0x0F25z&_N?EwvU%r5CW#R21BXlElAHOpKbFad) zb+WxO1AG^-9e6}L&R>jB0m*{%7JoHutQw#6j&9B1!Vxs>AJ*CmJ<3x{y7|Q6% z5H_722{M!!%7Q?{Wng}h$2zk&u>+^%zu~xx_hGqMfGdGq(+*9H@E<+C^Es{q!p098>}`(xc+Rf(_ZsLqI54*V*lCe2L*0f9 z_KEQfpB80U+27O6bBK6fI912E<331$Je{N0Wd?Rx8lBu5L;JXhk)sm^dk#}s6x$8W z(GH2%`-jC3cK5clEVlE^)6?q?u1*78Jwy6yr;hD2px;2(!K2;AR_jGMUnUP>=Y%@u zoc2J(?sD1QG`1|wuo-pxiENj=M)59W_YLB$JV9*s5-sI31CX?6a!!6uwgx@WNP@_O zPTR?`FH9$CpeXp4h6dO$-j|Dv7OW{Z$#jQrjJsKE7HXpxhXV|cuI5t-}qzbpr75!~;?wTnPbBc@SL}WjopZ~WU@jR>k$tUY$ z8zLhcV#WXYHE_-(?vPYhoC$j+%|JUD++p*Hfk_=!pWw#`$dv61yBSWS?lQj3k?jq? zsrS9>kQQ~h%&-^nNqhJXqZ!=x>5a~b{|g9E39i9D2xP@S506!Cxy4rVsLW=tt82_y zSIfm_!VQJB@%rlID)}kZfbj7HTwKj*{N$=X+~tG9VudSFZnz#hujsNjVH2xOGlZn^ zv8J>)A=g*!&9Wu(GrWampkW4MP9_*f3=|bj?8Yr6mBD$%oY~o1F3vJ?^MZ!8g<DZUPczxZ~c`U8^gD0N&ppvob!;AiMXewF(;+vG*4(EP>k-jHVT`Pdrtn4j!IPbw+w5t{Bz z_Cu4ba=!~p{uf@{{ax<2=;G7oehgEeKjfVEV~Z8b1v#ff`P>E!f#rr}LG%dk1!1_| zG%O3@^N=C5`VTqp*E@V65$uR+zp+o`ePw$K%5uPb;s>x89kl2zyX0TMK2UCM-$|(O zgyNyl4=Z*n*79DI_v?Z&0wjdac$sWx+8 z`iL-hQ3NIKsR)DAV+W>GZiRW6%ZjLP3HJq+&``q-19q%qYuK7w1-A-t%)Yh$)_M`& zEAnM*lVU9_l@nx^f%~6iR1Zs~R>3VZVZEo0`)rW!hYlpU4U;bEjCzQHoZpzDsccc=s9Qd%bDgLkZt$K|FbJOpnFv2SjFRRLfMYs+aN zR*>`UXF1*t#n0~Rra^< z$;!x8r4wsX@o%ic%g1L=O=tFnE|t%o^7zWOJACCq>2&AMIZ$#znI>~l$@vNc>{Zd# zAQJ5*$y_SG*GEQ_X?OmBh){p*DCSsEPuxBh7ARwKRpcAKTjm6t0_&-?Q2gj3`Qpc+ z@E8TaHsEsz9(3d#K1F75zta~10n6aRLWv&n>qZU*_Z1e^@y@p|FRkwOPjuYvTO4S! z#`#dj<_I?X+V*QoqQE`@>?t%M#(dCcTsN+5!u%8U){fY{M-fEy7vsFG*qg^mIX71S9W%4Atm+o=3wx-AHX~3!K z;Na7S!?gx)Yiri_^K0YA#YAttoNn-@$GI)G)^NovTe*cWV$c1NI%>zdO=HLjvBM~7 zkehT=R2o(`8}hDPp=+;SrK_*}*HCzsn$rW+((tPx)bI;_p@v_nCGJ?WqlRNF^N$2E z@ALn?Vko+L9q(p}kEwO{*91O=L2n)t{Ka46Oh&$DFgd|b+*kVGz6Z_VcjRb^!hL)n zPZ6Rh!hW(C&&y?W80Rd4yUaCSe{u z#{5|pTf}y<^D=K)vuwTWpzN633g7ds#y)4)6z+-=#TvyarJJ%zxl4In<*TYxZBgw| z9Z_92b1(}rD>7SRw#)2>d9-$SYs-d;Cs!)#k@58FA~71-^y_pz_D-)4WMcXaPny{|bWIc)0V)Mt60la4Nq6^_## z+Z=Z~UhNyyx32Gwz6Pgwr&Ugu`^EIz>1^v<>%80fsPl#XX8j%dhxE_xzp(%Q{#RWF zyCk?YyX;ZdSom^+TZgoBBdVZk)!0>@71M3E^8hBvf6*p(MP`4DfI=2;W zyWCE=T_5B#sCv-+K^q1g9CXe-+XJJ#0g(V-y+{y-|fDaM`}jS9=Tubpe|5v z^|SYD^IPP1*x$xK#eb9krGUJE0|7?^u4`g7+XHO_8v+jpUJvpP$_uIsY6)5%v^i*d z(C(mPLFa>Qg9itv2QLoZ6nr@NT!?*$TZkqkHl!?MPRPNKtE0R|<&9c1>Po18Xi?~z z(37LxN0*G=6vo2J!Zw7R8sjvkWX!g3v+%6&En^+WE*g7StJEfFH)(fhuj)c|({-zK z2Xxo;KKkkUgAuL~iy}@%hDOeh+!A>z$|fo^YFX5ts4LMy(UsBbqmPd>8<#L{*|<|N zUNLnsN5_YapFV!$_^Yweu_>`7vGZfM#C{Q{jPs34h%1X*6t^YrSlrckhxnNIwD`*S z#qnF>562&iKNEjx!r%!F6V^`HIN|h!^AoNo*e19qq$lJglq4)p*q(4R(JV0}F+DLS zu`O{^;9#N_a|iN<+%dl>I5!Q%R~tYHVsnYF%n`>ipCtsi)I4X_0B!Y1L_~)7GbLPP;nM zWn#(1*%LQSJUH=8x-#7-JvO}{y&`>1`uy}I>Fd%rrJu}j&+yJj$*9O^%UF}KBjZBm z;LNPd4Vh;%FJ@k!DyL{CYW zl0KzwO7oPqDT}6ToU(Pw&MAAR9L$mDMCK&row8rSejnQm0b)QlHYGQf+B0HgCu&T~WHR^kkW1nWn6^Y-ib_a_92F<(l$2&Eg>M+%RYRc55Q&&&jGa@~rZ& z3ag5#s;HV?HK%G()rzY1Ra>fdR_(1iSS_!%sdlPfRHLl%uGv)URoh&+SsoIOv z$TXX2;nNbPrBBP7Rx$0sG(%ld-G;i0_3HZg`i~pr4KWS#8V)yFG=?_jHO_C`+_=5* zaN`$^=Nqq2x0>!fJ!pFD^z`X<)0a)ZI>T#5$c*S2YiE4XWZN{jDY_}Usi#}J&VlpnUy`OV%D-* zyJqd1b!fKL?DW|Mv#Vz#o2d7Rymj2t-`=py?G^Sbk*SX2F8OlDhI#z>)%U9&Gt?TG z`AIX(M#AD`{4Q+jgXExmEBX&4L<|x6KH~NNkyQ35cNEOip-)Z`3NJqdbo+ zzH8dr?OFG0>0Q||l$ir~-IEvYCjl0YbU9foruJiWdHWYQUm~S297Dx>?K^P%hpZZ1 zD3UxI@R4Mv$l7kHKsg6x``jhX11>$AEO>cl2T7QG5pbFgxVXm7+l#xTRk(MDq)0@* z)x@ED!Z(5!_f(S4Cl)H~XVg8(n*kSov3sUPqzH6%rZAEu|9?oTB9z+#UIgKIr__S` z%_K>o7xD?`{Q7~?*4y*54X77hcK6f_T+pWF&FycCvU!_~a+&3F%4O8gR7UB(_KQ5p zHbOp;y1q}Au#ZWBlFP3Mrdy?G%+^fXvUJ1TYuP$jLN#Z=0X9{gH zjMaeFS3n!rtGtau2g+@wG#KrS^UOroDUYFD6hKdy=n$j6QDhP?LBDYQ#q~%S%0Zc2 zp9*dGFkm1l%5crw*Eu|^B#V`&k#6FAG4g}w@FU{be$i|?aWJ#MIdp?!DaqyUiSp9^ zg`nrop`*auA#_QS{H)Ygd6}Clr-uN%1|@~j6w`;e|k9$G=E6 z(pJo0s>w;rxizWw!?Cy%`uQ~Oxsa2>{e@&L(_k(VNp>0VkziYa7P?**Ny1d_$a_di z{2eJD&m#jYo_N_xtTN)~kP*`5o*L^W)Fewb5XXTyI^pOrFiI zYuQC1EtYSgPDo4HW^;u(?(ang{70?6-nw5@E#3CJIGit-D*2U+Ch z(%o3qgv9A!7fF*D>YDRPc$eqrZGp?uNt~A;@jmb@j?PH1*M#c=;;-;VzEx-kKY}OI zaUb@IN{=Jj0Oz56p~x%Q4)KT?)yq6_S~yJ|*IV)YEcp)U9Qvsyw69N)qH&&yv`m(V z=Srk(;>2Yi@|mQvB2AdmZ|W#(Ho44cP)J9m2J~>6kYwCW*-Eb<>1BIS_nD~sWwa1! z2IT!~*6^rgjT}CnnM@&#l&N zrrYCgFS>1Z+v4^Qx9#xDyzTbRAjd)OgO(0@deH0c%w6H$%iZ3+kGr$GtNU>G823E) z;vt7l%TAmB!QfjXz?iR{4TZ!yQeUc}qo|I?(j;KKmcGWE1dI=`6S4+iJP#Nzkv%1Q zQMO6;53Il-^8WI{@R4ccLBM!4)>=U3Y#=U&B%Cs9b?G-VlTT zWBGF)7JI7zsRXIaw1{uMA>>TP8NLLToEdp$<(Yyrb8wos6p zCAhPK=LF9|-R*yJt$5FP?3zRFYkDmoY;q-I>1-aG!d`%!E@6+cDqx(ANR$*-gw>Tf zES06PH}HL)9V`=Zx^m1bh|!S2Bm}eiTC5wHfT;L1#QaXickCKSBfezSLLP&!^*OQz zUnP2(O~YEdI<|+Ev(NA)o_+Wl!~5hz_;gQ`@5p)Vrf`j1$Bw!NDyRMF0DRRM{#{6> zj_T<+w5B9h!&b0bw4->I!T!en&epOm>{+&yeafDqnb@-PZC1>dv9H;3wu_BrT6TiH z$DV|D_cRNqlUOSo&th0Cn*=FQAV$0&Jm5~m8S}UPh{YO)_8U!fM30$V4>E}qlWa1D z)RIEB+>dtnZ_N20gunVdGMIdTIK9I} zgWWCFI|5 z3FJ5Q*}sz%Eci+$H%KbE3D0*s$)psM_=IFp8L6gyNfmV@)2K6)4I-ImpZ}KeFlI3&^SwPif1szMCreWk68czO7BgqTcRdXHL zNR!DXno3@wDWsIzlE2U(=)|F99v#6>vg7P4_6WAqA}K!2fE>96!RdYw7a z-{}qPu`qzSG6&X=^??R*W_?+IT0jfwB77FSjkdyH{}`Q5AEhmH0j4 zq3@CBX*5|yBgkqRg*cI2h~wN1y~65!)Y=apW?I!d@Un)S8sgUL=RANFFsO z`P70GP)m|a%}5!wBNenanMxf7-|k-dV|p#Nh3U~jUW&=MYW zlc3Xhf2_ha#0Y;_f{34ZE(g^%(zyaHYk+jF#Qi|tYsxWuOg2GE#d97VbV)jAIKL^K z%ZLUPih1P374jvXD~OtAOXo`5Uo4%Q5jWr_<}o)pw(-47%J6I#LaZ9R&&HXZ|>MgRU=wo^*rW&nMN$&EMkJNy2#r&I^Tib?6Hkkhcc+ z>+#%6D6;{%>!H_1;#UetJVzrbfczICXBF<&<4!)_;iVVh|B<+B3Taq3$SHtWiZUvN zw=^gz5V!_HU#3DwPJoUa-R+GL(2=I^bf={2({$wJ6qEzMYS3W{AsTH}hdz+g;Rf#b z0&>^VZiiJ)rV13617kPf*p*t&cg`athl&BS0p(T!iz2|u7x-T;a5P%Lh(k`rSqbvZ z5O~0AL(EZ$VN_`X}OdC*QKFTuQ>t3gK%5(SJrccCLY(#x_FLQ!WL*=NU zD%58&{x64JQUeN$Zp+*K`Q7pv?|0&Bywe@}?K#GS3;BS~C5Y>M;jcXe*xcbyuxR=0 zy9~C>`>=#$u;;ifq$2;u7hIvs5DQ?19?Y7&3M;7>wMD60$WmB6p0NI~V*=JZ*ux_G z0b?l#=!#n#=?gomAFLI85}3M>4+INwAa#S~=1s2CL9iA*$Z$Fsc8d?}rw?J1 z-GCkQ9|U4~!H)DMBVfh&!kQULN5HBa3400}jjFj_hW<4GI_)U=2$otP4Z`>>1oq1) z@(B&4qiGl&gYn#0s^zvQc?x~^R`lFIq5TKa2pUPFND%CRv*ZuKP90BzF^-C(@pJ+# z<6|@d{dpNpB*!tfN~S3^m8Q{&G@WM9Ogf2MFVL!?f(4U9bD^Qa$d5FSyaj89TRuf( z3@s+-XbJkwa#%KHux%>nR9cBXzlv7V8hprT8v6b^*a7vhhj^>5f}I+L7GR)+)W8m! z0bAk%tRV_5SWBVTVH3@QMf3=r1A7+M6s*8j*jID8eFY5=OMZuywSX=paj=*cLHa*~ z1Wbp0_yl_0Mp!;e=#z9QnLwW+33M4rq)*f3^ch&CNhBH8)3bCX?8)b#TW7+C4d|4!2BW{e7Ma!U<% zcLr>=t=w88nXo7SNm^i!ZYPhza@!$TXm7$|drPv|-l2P8zh}Xk+XqW+KSm?jGxS?}mVQUi(eLSb`UAZ{f20@bPxKP~nO>&MESM#;V%E%t^#(9%4dYX(; z;1rDia#-7z;083)$m{y?KHyW`Dt$^GS^5o`N0i06W?dqa!Db(VY1> z6QjDPF{*n8~UY*lV!J zuVPGe4I|lqux;$07{R^{yPSVR%@w0iIe8IrQ0p)jT>#5ni80|=jBwvTJkXoOmhFPA z{!jK6(PPy4HroT+-HPOpJdC`w>>ak3z03B&M&6Gx+Pyf5zBgCwj#v$smkhpCQZ1)A(jcK3R`ma~b=J ztR|~4>VAox!sy$K9Ke|PG)7)$*thH~`wsqx@5!6+LHt0LV00Nl{(>?135+ej#7N|G zj7UzA)9eEKkzHgzu}kb{cA5QGsVk@}o?fhKtS^O6DqT%!O?B~9%ZS4Ay28e)lFH&Hi-@9{hWx_9;_3!v zWMMvX;ZRqT-=K^VuEetR5^fO^tO%*>2nn|cjYU*PInpZnKJZQ;=V08Phe7UsQxdUPOp$yj1RZv0UZ&hVsgyV$0aBuc-Y3f|T+3 zg^dlx%J>eNa96KP5bq|K+|>liqN+ceq)0wQ6xv1B4U+df>mIUGF7;1nc9swe?JMi zQBm?VFie?d@>-ApTJ7(rQ%*FIv?vMxs366}PJE-Jf}$m=qm1}Vct=H9Pway4M2Y59Rs9epm@|7kCD=~lwUVWy4Fia>3v;*DqkWp zUursqoj6+*c4AW`a9vs4!F<0cy@XVR1SCQ#G(tiuBG96^qW}@EUcy7KR~1Wei@UTL z0dKW`;3&(|PCWR#XgdKGr5(k|%Xr%<>&#<6Ba$NQt{qe%3Wzq_RdNM_DIgRcC@#V6d`YxNBM8jW~b5QSt`fdKyf! z2MHk2di2Uh6Jd*z@Qw;rGQCMD72nNvGvRl z(VWqVrumGH)-ywFJu^EWigg+tsGMoqdL+I^k5V=Z#aT3W$cHlDx4vHOr>yjC$givv z>R00@w0pJ2PoMxtQF&&K-?*FoK`b=|J@#%r;h0Eu4#62Af@cvuG~lpkoUU*mI$o&bry zK%rrI-uKixB3t0R{sIP-SUTYyB^i`Ixw>9bwD@y3Fwd? z37_uyqr1MZ>4v|i8$OzD_-eZ0r|E{DCZb#UQC+VC{DkY&6um-MSzDGb*B3YBE5_wl zRpkqe4H%_SMb*}qSJqU^v+zh3g~w@SIFpZu$*YLVudU68VOUjAl+PwKvV=yKSq>wt zoSVulsa%#)R-;HQFRjX#rR6uOCW=MLlFG_u5lBh(C`vi8ilXAmhJ01AL~1i0 z$oRtsfz)zN>QsT$N`X`nX+3rxXk<<0sv41ASzTF;_$0z#Q7;hP0HPZuqH95LArdYX zH5^ZKUam+4KQkg{XL=z%mWmKAdPx_?><-^XrRy6*m<^PKs(~h}U-08jI^2Ful=nr*~~} zU3pCrA27n2FB0AoBuA|i6r;b|&(Bf<$V;>=t}e>2FB9@v7FTtg+tlXQ6<1dlmo$ig zh08jj3ixX_1u)=1jPbE>+4Q6(KAXat+L>L8 z@mK4khuzCMXB*u>x9k;N1>C!QIrOMzd*C%s@jH` z^~DWhrRW2#j8)CqYR>T!>ny*nu4YDKt%YzDFf$iUc}kvml>~R#t9!EjEaTLTG$5FP`HPu!5 zbyHC#B5Yyi(-uB(%n@RK)M5sjE9UnJPck>|G^bd0DqeFBIj((>Uc2q^EL~-{WW#0AvRSgt__CIzJQ}+jzal@Q7^cWmJgs^M7OQo1=x=BH3cHMU}-| zixn0xS!}mBVDW>+b<4q)sg|vlZ&-e4`J0v8%GpY7HNmRHYL3;@RN^I8KT<`%A=&k8p z*88u$k2%;nq&mFl@Oz)sKKrl|Znong$Il&q?5pZ)*H_&)rSGi1Tl=1LGI#QKn&h<1 z>0m!uzcKx0^xN+|z&RG{;+mb`aDE4C<1Y5^)jtp`;~wq5vj407-|v6k#SH7?v@TUH z8(iLU`P}7?0euGK4fuzvjcdK@@qwWOceq8kJ?nO5P~4ym?tR^#azBgDDbMrxZg9Zh zX9l+qsT^|Bv$vC)a0}&pw~;MyN+D8*$Dz+V@%CfBF70a@fe=k-CwoBg;ol8##02 z;*o1c?i=}$TBi0_r>bYD7pb?Xcd8Gmzf#}uv+>LITkQ9a->?3D{;mFR`(F$g6fh}Z zWx%Hz2Ti%={XiBN99R+fTHwz?fkCr^-U;p#JR^8(@R^W-A+;e*A+Lq}IBMXiaigY> zdS%qNp~FL)LJy7h9^E|pKVbvH#)K7wEezWmc4bVzG0|h%#%vpNHry_JO!%zu9pTr; z290eT`)}DQa`nTT%atIu!L$)Hl(J=yB1B(N)oH(JP|2MemRPZ5$mpYMgFd>$u0qJvHvb zai5O+YTS1*y<*14B*!d>c{1kNn8PtY#9WTKKAwzMjn5eW%=j0_zd8Q>@z=)Rij`xH zUUBT-WB0~>A19CN8>fy-jjM`liCYl&Y}}f-m*QTJdpquI{IGa+d~$qA{H*vD@f+hm zoxmpePiUO*%7m>Gc24+l!Y>KR1e=6D37HA=6ZR*3n($SkZDL7cbz)=UGl^>wUrPKY z$vUY|(txDFNyC!@l0uWFCN(6zm-JzB?_}p>_vB&8YOL0qkUTMYO7ha=mC0+9HzvQ9 z{6_MJ$;XmUCZA0qDXNrSDUK|Z=U?_-V0S^5T@gPq~r9a$Ir(bCPo&#oE26bJpZ+$k~#!11tFU z}1&=<-N)s%O{twDZg1E!%9Dwioq4bD>N0c zSm&2jQC~5uVrj+Nip>?fDh^hhthh9lOzk%{bZYU`nyF7t-7?_s(#??DpBMvZA6LJLZb{{cg{% z;Q2i(b@24BY%zIF#a}yiFvhUX`geyWEgA)-3`P5Q{N= zxLiWRvS^1@0`_RgOt3;TTgYp_HY? zjvd|sMOGNwuT+m|pTCmlt;*dk`}B5${&#o${XXC((F7We7_tk9XIu>KzJ!_a%^rta-{#UXQp^tAOZ9<@Lt0^2) zcIpr}@v?P8obykYl@Y;1h6ZS(&5+!$#c7e+(9VSb^?LT3B)N3W_sCFGXX*49?$Bqhz7fce!k!tW^N zXm{XM{(FlY!fy@B4GWVn3cU+Ae0BKX+XjX2rIM0MrtmB|JU6$E8+nq-@V`qLcdBAR z`I^)%kCp^E0z2mkx>JZz@2dF)P2M|S4~cn(JZn|xqUdGq>@(cQ#r79d+=Tw%ZWSPu zjqyi!DL3P7+qXQ^jJ1l+m%{t6|FHtt@eXm{9i?j%9Lb{eDXt(_2 z$jIoJ#EEI?*M9m@^w4A?T>EPnx;$(62=FoCI z2St`x&7N9-PyUonpB`&@{=C@3qHpeHgRl_%b@WlP&C&MJyjRzf-Bg>TM_5u4Im(h8 zl3H}_>Fw#Ht(6t+AlmkH+HgoqHnj@%SBaWvadwTF6dxa-QC8bfkr5poot6|tY~NYB zbm<<0vksvyd(HH}zqxGLvbS!Cm&}>Ow6^! zXWhDW{9>~_VbIt6UWjO~ZcpPsnvfvX3f3nO>esi;vag@UKRh*i-KJ}r{78gT>=QbW zmn;i3*EX%oP7U|hknGkLVrV0*O>WlP`OJ%Yu`K!AkI0Be^c)J{OmRK;({&Tsy3ew0 z(sbwtT3YTOMypIv$zsiy{RtITom8B=a7l}3cAt&f_M`^^rwsGwy^2w{%=;6F9-R;! zZGAl)*riLCF8}ITTl0@*Iu2ppKNG2`2cWaO--jyOEhs^ z+2ZWu;xsfSFE1~~)5+O~csicjx@y&`EoUA34D->UzuLuoNJDFz8I#jnMBaYW&=vtP z+=TvQE|%ZC*E+5}8!awFlvFODVym6P%O04OMO4BRhBQ zJj^r!<6?D)sX2j0ysTBG)$ZG83MEny_f9y`UEzpOO74pSnNOHDQiq_OHko0=gAvnB zCY6BK%F&%CwhAY#fmTXtiG=A9M-@hPvoI3Y(y4mHoI?n)v=DO&nye$Y46%%UTC6yj zV)9apoHsr3&EbUDSa-h3)nLcV@-X7;>aHa_WqBv(nZB=*+LC0A->VEDa3Whvw#!|e z9b3AVA=-FS8GMac3k?g?fy&*+qdY8(TyD^k!?GZM-<1&v@A?BFmqS}fSZk}GiDeB9 zhYsl=FPmB|BoQ~!D%h{mwpUl$SSNKx%#<9nijPfkGndNh7;nVg4ZBY=wB$9`ua8}1 z`z*vje}@RyH}8pFr>$3mGU>@YuvhNx>O5~|6xJZ!#CoAySd-PprZ(0`k`}TI!LG|l zn|%5t(v3IZ(L7_bzKdPaokx!et$)$oX!$B%OWt(x7})=@FrR?~2YGm_NBVtq@W2_B z-5{Ug!-sje^q-|eY}|ZV#kf0FfW5@Dhq3e&z^N65idL2@A?9Y1+?!Mw#4+c9X*IE? z(~|VKV1=rx;|)}%)L5`QilGuanoEno0r$JL7G)S&bW?xS6o#Bvm;?oT<=N^ zK%sS}9X>GLYmnO@I|WwJ-Y%tgsVPz447e-ZF=nPXk=dffG3ieD^hcYyaQw)TBPV}1 z3k%o722s2Bl^MJ}t!~~4A;-HJeFCQ{?gU{FAh_AeWD1pyL+?JXZ{Bp^$6pn;y?R-z zNPBKsMZ)kq!LhmzgG|&&4sgCJ#JVJ~y>`R)+Km<3(0P$GXProSA@HK1o^3MsAor;{ zj8m7%kDPSS6hva6%E!$I$p+g3&90WUVPTb1D=RCb2f35V))uoXzYudP*m--SU`ds= z=e18n?~*B>|1t5MqsJIzGkL|l@8-?k-Xn+kxS5OXBhp)Uyk=)4jRx(2XN@ ze#vQaxgPEf{1nqPa{0sHVYO$^;ZvtdN=|+F(!zy%By6hwoEzP-IZ<1C;$h&6=8aGX zjwXF`B-!1LJt2^G!`fKhLeg8w=2lUf-MX~t?m4C0lzTwd(d&|)@xHxJHhqzZo)f_v zAQfPaZBeA|RKN~1Rx$*;%UXo)v`x5&T}X*|-9m>}>EKy<4%qH)ohusRLLT?dJj6){ zZ`M_LPFDI}V26C&qyX(yr34HcSgmfXk-iN@85xr%7nGHjn-hcc*zuE!3Jdcmr=><;aBzm}5UL{M-iXw;Jy71NUF1}eXJXXM= z#DCAo=1ffqX*CNOG+=<6tA)WJwK=YT@3U-KMVes3$+-y!5|+2#4N9*LD3UCTy`l%_ zQVf`|8_N+c(@b)_{R+Qo)so|_t;DI7Txcb0MO`h}X?MumLgUa)Jb@Lt;o%WdmHk~- z9gbB5riHY2Q!oM_t+3Kx#``141j{DbEgd6%caE9R!sOZ6#Mq`VTxu0fQqvKxK!uQ3 zhP-KdELyfA2lxQCOJ)J0x}#h>H2ENAWC>!d*`=h)*~h)wd&Do@uOAhwl2``$V={%xSi6L zzR5a`CMaZVRAltIufF`v#WP5!)rN=osr7K0KZjK|k7jqp?|vGrSQin1sOL;2^KC+} z>|MY9^>_DtaQ?=P8>hAiz9VpXFFSBRD89wk=EH>x7cMj7fAUL5-}~GPte=Z25N&8a z`sQQ&-tY?rCzq9#B?oI1`{7R7uk2zb$qF>2tX1+0!8Jq-fU-$G%a?rArf*Ndz&l<3 z*hesrblf}=TV(G2m;FnV9{_|&Z&ZB$a4ywp$svVC?X%(mKy`Fzg5=cSKeQWj zpf+UMu#j~Tgg0ee+t;z7BSwt$3ke@H_Uq%vF83KYGIZ3aU_b9+dMu(`MwZK( zCO!b3|I3)#=u`bO4ZsRCF!h#_rAnt0Nl7Oj06SH;xt9CY3xG)YplxOA0|1kC%53(1 z){Ck@^OS7-XAc5I5Kd7WKYE`~pg}ukeyzLA{N9&djSz;Ncb=FNTIlTi#Df-SwB!k! z%FOY;o+C#140LknWo>7#B?}*G@b}GczrRnWqBnoPcKO=1-z1A~KCrlR@%P!w zboK=Hx4{r+CH}PcF}0@_+$UE$WDQ;U|H>Q(E`qlqFZPLZ|0n)*O6tQir&F)Ka-Y1p ztHjX%MW6*k;dVK?o2nILeD8f?+^;|r6Eb}Gh><~IqsM%8?Bh$l2aE^_4h|gYHB>J| zEi9wcQ~&phzN5)Dpv&yx?P4Hmjnez11X{x~Sxf$7M$Dy=d$h- z9wf{mEzRKp0MU*)W^NgorFjQ!+w_{Bm#ZE#$(yj3!hIW;s@jCKYu8oF;K7?iPn>MM z&(qi@N#mF9>a^C`}NCTP>Ce(Oy?S z{`ljoUeO79;TF|Hl`U$WYjNwBv&Z(o{`ZaeYgMMCr3BtMwk4{)9zH>sz5GX0QY8H+ zctQm$T$Y`YHa!Lt&pY63Tnv~`qAOdA@my23@Vv!xwB?QS$BrGlV&NQ-CETJ}0Jx;J z)z-(yN8`iJoViL=7G_s46o;|hVp*9T+mD>twEXdw77Qn0(@8KnZHj$fIoI!-)dXF@ zDp`@?epxKatT3C#z{4mL9%B2sgmWSXdu^CLeZy^BWO_2ab*?$*;X9kwuG_frvAeQR zkkoQxO+6r#VKq9ejQ?xPeL+*Cq;$58numfY)9a%#b$FUAl*u>W7f`d56yxNwKq>FB zTOOF7N`1dkld`R>^zg{wreYWMQLqCvq!%Zf?`NfXN_8H>%v3<7H z>&I_+j*iSqOVDZh%OwB6i#>>+9QX|^)?t^Fr_j&$44S4siA_C-s{**jCZzE2(WT64 zdylcTwZjo2w(3uN5NFQc$`Zs~SkA1-j6^g})cBYf{Wz$+GJR>#n&7Q20E^&-+^k9vcahy#jRpi2?N{$OiuPjK>KRE`y?ki*;yn%1xx1Il#27{sZ@Wg@%=pIg7w8zr1Bn zt8Cq=-+q{DF1z;Y;J%jjuKg@UyFl>Z3$unTGOxNFUYPRbQ4*2|zqiS3jS^#Ao&%GK z#!LcrzMzimXjOT6)z$5=vUPwPjHVpXwoh*%UgAu;^nbT@W`WmQ5Xa>OFPe?ib8E!1 zdJAQJb!&jz(W9A}nPbRNZ1vxztZr-;TP`A}33XJqMR3W>Jp=s-@Qb)E?8NfIdmu? zLGL@bpW?<1#N&+lGd%A;znRb-Qdjy<6==yW#b7s=`G1DK(aj^(N^i@w9WhV`?){AcCgb3|9z9imki{B@XIns3#Z<7s1KZAlDf{fCW& zqqybIKq^k`+V%PyJ6e_P?YD0JVQ3e9-NFp;J=#lj1Pq$Ghl6O&&B;4Lo!Xs3+b$Fy z%*#9YkW`v;Kf}YoLER}3ADXx>L~i=Zcxy7RKD zW>dsz$=k{qZ_)Y@e}+P*8$i?-5#3AfsxRb`k@7z)k`MR=EX5veH_uP5OS%)7yA3o( zfAg?!F=1~>bh`YUhk#r7x*k#yW zN$wmKE{^!W2R0&hh>GO%a(igZaUBLq=Y`;j<(PdW^B0J`;4_cpB7zQS5@Ln<|2mi> zo9Hz-T<|~ayS03>(wi^vpx&6WJRp`FcCYu?wE=}XzW52SyT9J0pX4=h59k&=?){uR zDL!V~9T0&LFPV%&P4Hge9%X5S*l>mBYGvisI~CTgm--X>J{Y6LX5)5uDoE@~MD7j) zO7sHLVoS%8FZ7s9KSd7ElNcOaL=_x>cL#@c!l6A|DQg_4Hv<3t_#)x4DKcgbihaZG-{XTidG9g$uHa=%cPJ(_&w}S4eQH439 zZY4X}G*?WSlAB*vT~*UdDR+xX%&jQL?hsj%B4OzBWd<9`W8JFejtAb1aaiJPZO35IMKswPcHA!+Y|mO);mSeOktwLZ~AZRucc|-`?T9|)OsFoa+HCRX}&K8Dm{{N`E?-kE5M|wbmqW-dC$BY@Pi%FP} z`1855WXQ;|vE#>&(}j-WM(8?tFBe)D4w`U{uev54`}Hj zjJpJ6iC7C`%O2>t1@6iP?)IqGGosuX74vEj@%f{PTJkFE+q+j}`%E&8d`pLsH+q`U zov{ihdI;;zWnzhNM1M(bu^oUNcGF%#9!22Q0t^&+WcUVLFJe_aBsNAtzne)dO45%# zMB&jiE!oT*?QOK}O|Byc+S@xg4)Aavy#K8?KfNJ$80hNi;@HckddAEtp*=+2!v&uS zy6O3zpxLrmEuTGGzCSE%e-BaAZ9Lo~b})l=-lHws)C>GfPw-JytX}V!eW8bVXyR4g z*%R2I2eQx83+oV8SQqq{+gSA*?mfJJuPgsMz7c%PgKvPnW<5LA{nzU@abrh}937L8 zswFQ=BOyMc^*@i#z+a}ZyGz^4<{QTvYU;b=AbaYOAeSOa8p{9R5M2O-V+>aT}(pC}n%^7-M zOYBTx@+Zm6M?FCkEYb;Mu(6?Lv`(u(^Z94LIJx_e2@4C=jPT)$0&f3iR^h>vZi&?Sa-;um5!B zyFa$AU-Q>htFG5YqK|!E^jFBL)Yt!LGn4fXJ+@-?d(PMw;u*5i;igTW{)5%y;^FK# zbLi4_&2!Ib$azGQ7=FL}%gr|B&z~MX?C||FdaTLBPyUxrh~FH&7-1rx9`4k~98QfE zt1KV86JMUaaOA+AlNK?;WL=B7LSeY3804Sg{l4T7kuThP>FiH!N~*km_VmUtTV=7a zhYyP*+9J?r8{pyU>gwj<={Etp5+vy&4SZSJZg~FJS{Q!Wh0O`J|6mg|Q_pYUQ@G8e zok$+=ymixX^Hv+y0U0QrOP4JJK5OLDM;MO44^zN@^2NstLuz$=mla7)QaLmat6Suj z?J(c*B~uI5y>ZjKK7#xUuA=>bmhEk^V@<(~RBw_A07^6R>-9EvVek6L_BrhpV)z+* zl>|jXg^8gyPq2m@jp!%MyWg@;x?{x^OL!m;~8TkqZ@L&s>L<;IL~8FFs#-o4*@ zr_A8HV~Fb-H<(w&U?EaL!Cq1TDW#BqS)14zgeFis#Bgk}u?Z|VVi+-9XaZ;<(p=0e z!0{Mhjfg}`7nkqmuY7*l>I3Kg^Y+0DE+HWy>cKAOUu|6#;Eh`xJcL_rU>cBL^&EWAWY32jjwASz6kO6Q&SD zoZ74%d^P%s$+{&=mi?=@wlI>sAaVNz^IVN#!@`vGgb27+SI{e0@P7-52244(>beOP-l>oc$p?KRO z`2!w|YXWGKnTy(of~|PI*TiohhG?NFh`q)#4*AZ5@(1oq=IZV|0DssXt@`1(BCLIR zu=-H^A9vpa5LL1LKXdo)vcU33Ktw=9L_|bHL?T35{)mK#MudVfQ3p>az}$pDj4Hfm*MJ z$JB?g+*(5ZDirm)x)?FDGAjD{hvzPt69W`}oaj7@3Kk@Kx1O$ciBC$3PnsoAXZg!r+FLk#D;#kRTD_NmhGI#E| zb8{CnhVTH66>>elc(d@Mo}0KUR-x(EfXpX=7BkIcKqwx#5*)rX1`ZzqyJEqD>^vrC zI|wyyxNbaf05TXxKJiX4k|KQ^JgCP>YX7zyt4{j-UI!l+6<<2(Nrj%~<}Q;cz%-<* zJkVXjB1^bEU6s){JKGm&lZx(AFGkXOX}ZS8{+Ydjn;Ip(J|)-dyI~PJ&-?9|{~@MC zsbG$WBqK$yU6S7>=szlM)|V3qTq#wHdAai{F=xKkmlLC|n9uJwQ`gS-eI@$|z$XYS zTAM2;>a|PHJnZor^FS)#eIQQL(eC^;MYYEvqvJ4j-3!x@_eimAU!t5N#W(SYgci%qHU?PQ`_>;{TsYDg|pPf zI@6^~mzYOXc08{rEFhP-$|t>qI$EcXgX$eS-Xz=ff}3|Ag9Nw)AFt^yQn2FwyhY~E zyX!>S2&9ybCxaw`l;dzB=jj@<9lnHcN1;7Sw(Fm^$gPW5D&v@`iwgbyWBKq?ibACO z`|}T1c4_*fN!@fD)h+ePg4^3nIdsCDdG78K<=KKMTB5r<;6mT% z60U*xk{pX>=Ed~bQHfw4B^ml+0?!`nOqHg&(s;tw6h+5?lV%oJ6*=J8t~>3O?9PJ|mSpfkT2?1;~!-NlBb( zjwBg2-EZ^Pat#D=t*yU)Uj--IuqrAr73j&+ai)jlC=c7bQhw>z%ZTzrB{WdC$~|Wa zgRncY*=u^tvt1_Pz;vYCV~Q*o%^o4HOO&NzS+?P<-oURg28V`g$uY5@Vf_t87AdDT zaWtoB%!0VbbtM`*JDKWHj-Akr=li$b}%Jr?ruo7NfqVm=#LqNrKuxuxev$F^UTAW=$uIHohGr^$*eTnK2k{tspy< zdq!4|Qx_6of+pnmDicc_p55b|EEX1eQ=(2ZJio{JH5M|3=!me84YAPfHql4ua9yv8 zX;w_)LPv=%JksObEj$IvGL+sF+MIQ}l(FaK=Hf5~6Nkk@Ydz31ISCqC6U+9(Cn6_ZJpEE$l$(=|!=XwWea z3t{?e64x^!P>%*9%W;Ci(lab0oL=g#1-w^v;><{AH}&$wpR5-%V%}V(v}5X)p4EvZ zrg~ZGm0i=cJ*}bZpzbxzE#nf`;kq&E^q&=aL-DF}&YuRHqkETHK%O0ZT3yrdoaf@} zRWNAmi$w-=pUv>hWE5iIcS}2S?}#x3j?uS)6!gfl~tQ|9pg#VZUM6^_Mc9P zgSLMe=4q>Y(A6Z?vQI`%(P^XCmbI&c>#Jco*t4E3%@CQyt_K+bCnk;!3JMOn{f;Q@ zxl>>L>gpXlHXfPHZta~eNN%pI5fjhK zmm)1yKz6eqoBSOkf5#fqdK~{CUy^g!^6x8Fei+8WotIv(ZfUV`^R&HunJ|yMlTDeT z?xj;rF7m9K%b|Lhp3cJad-n;;3Sb-;*qpwn(CUQvL^xeLkDVgbrI1)$Cx^CawlqVS z*PC3F!|r@A$J3K-b;*vuD3Uxqub*3|DYlg7a^$J77fd7u(yV`wXZqe1WZVrWD;qwl zzM>RG(Ymisz{t_z(6$$KdxW5=@la&MhDA7DhwOT_^_)aFLK-{Flf=NIxyE0cF)tR2 ze%yDLX3<#JKTdtH_pNsh9k0HO^&eyN$vv^ACrq=U*_UI#^8M4ly7>Et1pE7YsM~z1 zKYNSd`EH1o{7ty4*I+qy0;^qXWc|%SW@D}?dnZ?zFwoTFs8e09;p^K8KC`Ni)S7G^ z=O$WnP#cVDKMzeyO-)Vhg`X~7JRcO7fE`rE4)f|YxPG0$y}=G(OyYAB@V8;(H}`AF zd&)rr`WN>a;%h3{Qj=-Z&B5ntuHWCBM~BtaKvnZ@S1i-^w(EFws?@Ih-Z7p2ev1m1 zE`4}*`k0ogs;Z_D$;m;rwYAMYiI0v)Pv3{Zp8Kb=f`6(@zlJ7Fn}it}8&@{La7c|k zPLHC4vtuPh0XiLX-3YG+%sPgyqrr9%CakTXfe+#wIoOpnPpDN@xsCK za(jDs&80Kt zu&zhNG`oIkbL}Oy5tjyCzrtna?yl6Pi ze1V?!#?GKZQjcn|U7*5gbyDXVx|SMwXJvW!DCu^u;E#1qwf{Ak8R+Kd=;$>zu_%t{ zFhcUpcWm;q?-{SnnVTHdqx`y6!r&#toPDql^W4Nd0$KdH4<}2GX{f-w@|}qRFRZ8FK9WZ3lZUt`%}k3OrY)vwB91kBQn2cIlc#+JiB7=8ojNHuW4qS{x9#Y7ZPC9(kE`y)k#WzB28|$I5l(%ABSPpv1T4_g6Rtk9dQYkbVM;1dJ7)ajw z?SX;Bk$d&ZG36MTx4^ZQvMpBQmftAxO^Z0S zJ>CwVu5GtNCGyjgWPJ($8ua2Cy*{b@ndtfMG8JK*q~nsPs3l$NI;*&@Gf9Z1B8~)n zDC+grFsbWS@mZ!m4bwcFq!CZe!X!}T$Kb#?XsYHV%(?)x7g z3#mJ&aCF1o1p_t2MXz(BRDp=rwtXDETTY!i^{?Nk(I8q`iDIi>sQ>X-y`#6f?#H_G z7wT$jfBdmYwDb0sc>1x(l4?{2Hs8OT5?1($l*?4WgSUE#)aM?+y)KrE-Jm_CNS-loX&h71;>S~t zkI$QtOp@Y<@eLq`#V5r`x|MAHxl-Pp*ESe-*keWxadYyTDec~?8JD2>yyEz0M-Nt%9|;bQh=_%aa1m4eqjh!BrhHSrxM0B}j}*mXn0)DY{f0_HI&*D_u@sK2C(>)$$^IwJ;l3)qv>vTWu(n5B}M?}oJR zn9PRO_D!_0!7@w~6<%p)dEsgP5s+7%v;io`^4kG>j7e*%fgjGwN^Z4EON%h7(d?Dd zU1g<~oYs?UzK>&|p5X7I9^(&LB~gIYNC9q`hPXsS*!!k>Y0ljP;dOl2OfA`FJ9lnM z!qC>bx;jm4HgjRTha4AkAA4fnqlvJ*lHBM0i>lyhUmE(?3G-$G-byI!i=uG}+vieJ ztz2uL?)$H|cW&R2mX?+p^!v%T+GINPnX_gzM5hdP~oca_=~)mwQJDOQp;GF#FvX zO+F`o0qho=Xm;QfBJr)w#hM?_*EQ5npT22R95#Yji)vj-N`xg-2OF>zWi(%gS{O9w z;bQJM(Q0esY0}ap0>GzbiIs#$5pw->?!D66x$;{FNvBtUCrDtj=^$`Z2P29!iNed@ zt~8fT`gXeft#s!v-!a~c9>Y+7NEN(kZu$!(jG2RIsh3RsP`gfj%-(Fx?Sw|-pV;tE z9GjO;pWx55ZP(J2FzM^!&aK3G;BV>;iu!L(pO&c5bI|ozuwX$fDE)JcEGbT(ZbC=n zD+e|+em{M>&~ESEy>^fC(MU%Y=&d}RgqDU2)zx}+b@hdY7QxBWQ%^jjP0^+dVRuXH zcy#Cw$N^If!p%Z-!)viv1d7J(fesY$khxw)qwLDEJ)eJmrp~~YHgIT)!u_m1+gnyv zP*8>~MXbv9l^;5E=&Or*wc8zwAd8(lbGpR&^q2I!d5r)y_S5}^5JH-c0N-G0jVBvu zsf3@>xXdC_Q8ykNAUBX*rt@SI*@Rd-*uSK`?h7Qc=I%XtRMG&4p0t5jez8kVx5IbN zeB9@ND8Qw${g02s8gVNK1L8>~XLON(xklY8k{+1*#CYuR%I6q}sB@V6K$0;j&adJ4 z@#77CaYcq%EPdi01ql!E6|5tTNpz70IJ9q8cWkK_8kO?4M zrPETlAeKr$8_xuP7ne!oJdOeo-8I_u8@$TM~Y{sp;HJVC5Hr{osQV+&*F z={)4WMGuK?4?&RfV{;359meJX5=~@M5l9#?;?bNON$Z*TO(NAe@*%ukjU}pgWQFTJ z7$#PezuM*d<>w^3!$8G7IVXQ^G>c2;0+>?0UXhlzb7yhSszicE?u%Q^ILGd>1%kWz zJ{R|jzI`+pcJ`8;%ExcB|8ei87}MjX`KEljZXMI;d`PkExV@I`inxcA={2I0w|Cft zm~nvvy|F5w6C3NBeePyH0oW!H(0x}gx%1-AooQ*d>teb3<}}k{aep0#UxVUm3lWxg zfU8?O1{vHWTiDk^XR3`QBxj{MZ=cMYq1B|CPdIj%{FCQ zCB$nZA|i&n>eaO$KDB1enspnuN_~iNR&v`^VNQoO($UfV4bJ^XM?~M7pP!$hjd1$1 zbl1+$og!v3(|>8d`mKtY5hNdNk(s$=i$PQU9q}0m>>ubuF8m0L-zhE_NPbvjR$YDe zdolpqBB5s5X@F1w;AFZ#3ow9H8_(A{m`{Zd$U4Wo{KCS*1!<9ygD;eol{F^9VtGH7 zI=;jLG!DUq6iP*tLYgt$plEDrXx2+aHaGWi^XNOkv%hc1q(%`Y=@uJ0d z`u@JRth~H@$&#pXQDKVnAN_3=W*<>eQr_3&kbMA`1&3@kNlQKu7L!PsE$_gKINpFm zW|DkWYs1BwZ@xSxdHsT8Q+nbP{H2;4H;zGy2c*3+V* zWiS{s82T`!f>LzKRisENfz;M8JLTML$2&gy_s)_Qmg(5VynVCP_+A;-+s43R*4umg zcJ?p9$7Bw4g$+y`u;!Hji;+qa1!-6bb{7w0cX-NJJj{so22cdzQUX8iq;8o+FAexw zEnccy`dB#m8oj%aAM>qrYu?ypZ(sUKEbw$5J3OBGqGeEmwvO1hn)+cTn6_Za{W0+1 zGDy(`H!V6v?U9iA($r}OdD`Kg&_NA{4(#8*KPoE9{lba2;y~}y`H?TIb`lL5dikPK zebG%+dwM%i85TEb#OJ(oJlSBXBP&csIO29;jEC_Oir}Z0pazF?EtehF{)yn?tI=X} ztp)c+GNIpgGP^wqOGaBtZ4~N&F*bEz7gu1VmTkz&bEQ8_)smOs{t|D&ZW}KZGY~9f zPoDVl#+FgTx``zi``#N3BcCUMkK6StCACkEW#iZv)>NfdDOw8(7&Ti9w0Q}L`8r(~ zXaJdy#lZL^rX?%rytHCzP$*Pt7L2{hL?>$`%wQ4`0s>J&wGeh;$yjmXL`)QSZO%=% z3~UPFf+@rflx>GZ%Uvw^KW5=jQB;8wH5Tbenx5>G>tE^x^W;9k`zFCyr68!g4W@J8 z$2M@bU8-Lxc9tAlx=WYu`n2_H9mz(M(aLNlcfo%Lo?oYz$2CxRXQwuKFXs-WK$7cG zS*&MWTU8$C>u@Q5y1N!YK)RpTSik8O4#zfXDabSAGkCoEBqOp-= z$?Ur#TiMu%1V!&7M~?V1kIDD|tyiLV5~)d0($?1c`qowobz*Egz~xRErU!-hgvsOK zW%xC+#WWq^--IOLAF-w!5cTO~izSuGk!yv`nUxjQjlZDg&sG?k$q^E4WMeAdj`mD ze7sv5pG`$#91kYG2F^2%Ly(|j(b&I1sx2QT?LzOAe#AJzB5trLp9G_ld3-r}>8lQW z{JAzr;GhZ7Wj>x!Z^p-$VN0r*hZe*EwIw!wnPcvh)X<>TarVN6W5%GAdBdbN%)Hk^Xt z(kaqERZ1~6J({}UNQVf`1uAze<%wM-EYBdPv6Nl(iEJ+ml8(vVattnT{)XcvQsOXs zUSgcn-M(&D;d}9_-yKV$Sv$Jh6GuDQ z8ZR9?-jOKiZCbxP_~z=>tGA!{$&beu{753F6Dt7P3Yt4x&+@!)`qYr1L5h-VXa=jk zg);*FFjt?{dYN+lVTJ|P388&xYUa#EOO`B|6*D%-+X((14{eQsipFn%j#Yw38h;|B zktKRPg%<|POfewqyUs_V3NbSNtB|Vzr5JOspU&=8)zGNePqvCvf=`vKVrvy8;;NFb z!?T!JsLa0kmqvTv5fd^pGLml(!lyc6OlE#8CWK6!w?i3JQK8iykCQDU=bMUztsh)8 zh*z47HfCuK%N01;Hh18(kXslFf{taqPl#C}un3ZPOR6FHt952rrIHlMC1I^N9vt== z*nXC=s>1k^%+5GICEsDc$_gA+DLqBf-9*%xnqBjR0c-yrjNLeSLv9SPi1t+41ZQ`nMK}Rj~ZGg3(}6 zqAMtw^|Dp6Euf`sz5vUAy`rirHC6Axe5geYl$-$A*K9I-a0BgPEw-0tw8rq6j#NAcVU&-pa1{z>#>Pv(dE5~< zZ6YZqoA~h^T+ubbQPp!c}Ie8e@Q%hKqfH~VfsZ0oWgCSWX+#dz< zv0AUQZMy2>8yMAEb724JD+W#Nv7^-UlDFz7lb^j2wY`ljx0xJdf3kFE-0i}_t!Z{kB~}>da_m|Y-o{;4 zGIoN-fxmRp6Q>dag{b;vQfg}UeI3Bw*)e@{|9F_aw_N|eR(olvv8HC;yv#)Hm_e?t zF zV2vmP0)6ykdOP-l0<19CSup_Sw0b|}Ik$}L*6K@oM|OGbNv%6hu1mRfJIN3d z^J=T)@c0A;$__DZgl?wdIq^ctUM0pM>}$&U*QYEmFoBE)7Dgxt6h}MBw>q`AN@s zILy#*YX7$f0Sg`EXmEG(^S}Jw8|6E9CMWOQS^mbm4L<%g<=mw%G@#g)X6)FeSda`Q z0G-I3MJfy=Dh98NR49%ywwj1(GCUqIBdJIHEIjKz^HpnLf#h78Gf zE<-x@0Lj)0GgvFE(JMY-jD;;2i>pKJn`;R83InZp+VE$13zVg8^ebicG{V*3Y(F$q zcHa;No10auR-FsOtZzR#$B%uA+^`9YwdAjw*`vVo^@*5$-;4q;uLP9vSuP2AORmXR zRre~UEs4Z<;b{E5_Uz~TH!VMV_Pd(;%iz0!Uy3EZ7oU$ch4Evy;ve;cAB|^XQOHs! zU;%BS>3-G9o%_yP!#&)1$vvDY562>_RU1nWpS}F`VXY~{f}#oGkaWph661PRpT7M; z?WMXO45C_S^nW6T95lUSn!;p>3DgsO%3f?5c$gpWlfTfJ1x#MB0+W_Sxs_l@1*MuA ztVShACeN4@_Fx>AYidoOu!RM^)#{k@_Tdwz%pH&7GEvknB=#3N(Xj*dG4l|!010D( zj~{1YkJlOcFmg?Lxyrz1)7E~2oULIU0A;%*Z0X&8`~yXJ4XpiU94`pB4+d19=+&yL zf^|Q?uxMt|@I>Lmm}nI9#j+R38lBK`Rj{aEV9IuL{oa9`)Ykp%96jCL17}Pi2CSuS z2f}i*4QLV=+1$mzP>X)|dMw<9WwOJdZ2s-HU!XmrH3co_{bW4SVNxkR8zIH^@G@P2 zY5&rtr%#`%tsfl&SiqADM$C6QYVixu;xATKe2?|AZCX=$hn0>td|rmc#XWECJFhS} ze!1^E_#zLOm^LmZ<&t$k8k7s(;nLVjvRze}lb-IL2?_M;66V@2ai2Uf<^H+TllQ=Z zH*GZ;tgW4X|FuPP`RuPo1BqioyR&P|6S{o+x2tR>O_?%f>y~ZX_x&Z;Qvw>sJC*Q~^YvL-TBGm}Tv`t1fmWr;(zSL88ykm+3_MuBvN0NiuU1!Aw~RQ`Ri*!K?)jq9W2}X1^GG{z?uny$v{6? z2?Y*D!SaTYs%Ol;!B-w{^}~u4bC1vA%R#=XhO^ZQ@ya8f zBG{W1_lIJ-p|et1llpTP8e0u~1yH%VxPquvd>AYdZX-wHVBj|mP)s#FE__PHFz=lh zCS?p~{JZs)#+oxlDt&&wo(EHmF@?(RFxk(xO~9oru?H zJaT~E1Yhtc%T&qnQC3Hf9*tu{KO6_hRw2Ijn1Lk?RF6kX%LXermyjsET5ul*Y#-)s z?voNeO_%XWy33L8@{yPL<9BDk#>BBg;$Te7oPLdsF=Q(ay)si<1L>{~GgX$Q81K)Ze}iY1ppc{6Q!j3ZT|AVN^iGSpOgo z&j3(gmC%1wu-cA9W4)tDI3F@?0w|Y+FmD6KPskC-a^ry*ui;L5{p9BXicHc z&Q29HvLD!?3un$;5CjRM?*&vnqmGK(2g_RBx*HNn6^LZJrB!y1^8f{o;i5QYjId;h zfcpN`0;Rg3#x}^PZD~p3a~Y2nO%72Xot8eWm#EC$cy>c9gF-DBx^gb>>;~O5ZRpls zIyO7GaoX4F7k7!zj%^uqDiz~Eb#NTcdO}x}^Lf1Q4R$$q#I6sNkB+)|4C~Vo!`kbJ z+k^Cu*NjIO5c2*!%W1JP^#ZG#G5riqOY)2HhSR5T4Y0$t`M5Jl46HF#H&l z4PJ!AIUpbba_eU>N|iy@DUqcOHur~<_xC3ygvL~Pu_d}hwtA?c4&I(>(e0vIdGRte z2%;1Gf#u69h6N69ow(qRg+weT9yru6DD<9K^i@uC6!Sxl)OJ`WmZ+4OH)3-h8g5@K z*oS9L0PnS1hN2u-D{Qh7+?jaJJt3=bJb+|^Saxg^3bTM4<$9eGpKUtaU{E$3-jo$? z54r>?oFY4E@5VT=i>drrpxC%KDLV%0KA;j39l*#7o&%DRXqFvcYAfe6wXhYOPBSdG zHkQ2#`RZqVLQ)wIvWM)oYiRhxE&R^uSjV|5+)fis{ir{kJzGy^gD+YyCf%+%y)VwR zM9y}xSa#;j?}EX~RDZH!|G_PrVk9bs0biz(b6D9&_5;xS$g8%qLg`WHe0=2)9{&g| zFI5p+HW-cB*|FFIJpfaNx2*E=^3rY#A$fXH>>C_8o9X&71){geD(i`^-*43EbWg7G ziowo5C^lQ$v$psD^-ztgM;}p?N{tmK8*6Aj!W4r|6NNHzl4+8k5z7jb3X|xrbiE?o zn7&J1L1b90P+Kvz)V<^_@v$)bZ^0>m`!!`N)>hl!9*x%8q;PZ$2w;$~cH50o%n2Ji z(#OZg%|;|Xm_J?pqc!kuh76oQi$x=a6Y^SUn(*9-8oe|rEwGuAF`}udiC76&uS)&> z8CEwowAe;)3-dAAuig4b7`P6`?63n7jJ|D)RkCXbS0 ztyPS7B_eQFbPp*z`v(x|j@iecn}c^T>==~6 z4#ksSiC_{+@Vr#2LnhB=ENxK{cG3}=!E3Jf*3~HmkYdn)mD>+Bn2w&M1!} zWj5{8p)7s;0&IxmMH}lMtG;V6Xd1q&slm>E$Yy^mM5-O!h0lNjZ9gjFTcO-}K@VsVw|%e}}uH9qtZyxI5Y5 z?ox-liyiLvneS8#f(oM2%Q;Hap(96guguX%?}9trnI%%vJKqlP96Q|Acev~LT~vp6 z=9qT^HtMD-wo~;Fik%A0oay&W2nuj2wsRRCG@9w>WZoGZ1QlDAU^_H^f7S1X{+GYm zA3xMq5KGJsqG|1Q-LLC(daon~C}=6qKY*_ErD#DOB}7YfyXKk*HF;xWr{-W=#)w^Kgaj znXN((8xEb5@oKBfNX(!jT*;3gq2`WWY|z`4mL86e90>QQpnyiB+`}GVrBl)3K*0`r zx$5Jmi0AM-(K?x#W4(O_xVpMJ+iRM>z*zLf@0!6A@M40G1}h#qrC{rN_L9-yGBEh! zapX{Ot|2`o2xLJ}z@S0zkP_)iE`Gdw_wJ7`l78dNZRUjB$QivdA|j&|RmGOsNU017 z|US{6?czXR)WrdYg|@V)>tnSL_8+1vHThw zy-rj+xfp-fi~VT*CCGEGT&kyTEv?Na7bms8U&<(#CgvQ$B{(G|z^48LK>M=Z8fF$! z4fDQJkCMrK2bvm0$h#B+38{|FyM-|O$`aldOYsJHr(k$D3;HY*$;)&AwIe^1YVrd= zF4KN=C=H``(`@sjJ?L;6LdViO_z^}&AWmQUclu9$?*NYT4vxabPRNK{4=E*h zu~H>#hHz8Bwt)zcQPaJX%y3~Gsl=e{54(lkP?2h8|E#Czl`NZ1SoRbOc0){8$(YVO zn*=P4C#_^Do8^M_X#gHAT{@ne#K8s)oas_f+KO(wL?4ST!QlZwRsjJYE12~GbN25>4zqZ?jRNtN$~A1#q@;<75b5$czW8_qu>m*`Cx8D# zS!(h5^@CD?=g!@YoTkl0!*bcpRD?!uCC_b-(PucCpuB-$vPkHJT}AJj|HS7aswHt_|olU01-OQ?dHl>O!EhAWNrRbPBudHPIc z<+}0UDzl(S$&QDeR=#-P#EHv;eC^Roe?EP>^6UqznehA(^{Q8Pl^>|O#5PcX;zfJC zTWZ*PF^d>T!GLHA1R{;>tEYVkaRDCT1s+f9;jOU@H{Lnko|93W>^cAz!tJz^B2No(WJ7Yi1}VmR6c?&mM!f`W@M z33dGHLMsvb4-Y*0F^GOZbV#KIHVQe2A=A6XNXKfNn=O9*9Q103e(b9Mxcl#hj7?7# zf@yzGK3;_o^wqYz*C35w$l?h1lSlQ=ZhjMU)lKK>#oLmnCx#wx=36%L`C< ziq1PS$n}9F+uH&iqV2w89v$y;k%MhixXa9WIn!=)Q@?>}M4{;slaJ{Ram%_DFJc{{ z!14KA2M%4hBRM1Up%CHFApuN-j$RfgfaI-%P1Z(PduJ<+e_CsLfc(`U*l1egF`t?y z&qz{Dj|pdj67*76l3-+QMl(@SWoD>(txHdU7*ecVEY_9Fh7pcd^^KxO!0j=vetk6o zo_!q~4s6=A>BA-`o4%fIZaG*{)F@AF-@Iw9Zm-~TUknJ_aWC%@^t?Knn>^cRNE8{QD0hUX_emA-__F<@^@!XKYxE2?o)tU;h|Aey{BNM z`Dy#XZ(%1$heVDFnVh$@s4#DL`I)*tK1uoGL9Cg`aw}$lD_oygy0m=n-ZJRf-JPCz z()i8W>-Ks6_U6;ErbqcvNc|Q}fHLlOOsmT#*R4vx%zZAFDDIU9pV_8`R&O4uw)Gtu6chuzJxvo35?OZpifw0J9oluVe5BP)_R2}FO5Zgu4j{7sn3i=`RuBz4(@+_{e}%2*1fj> zU{yThJWSQ(BHc!}k&EbKF?#Fm_`UJYN0rr$=p7h^{>AF$%7q{6*o1CN--mDWX=ZNk zUORNIy75;C@vUv`gRtZx3Hjf|!pn0&u+`R!W-@nozD{4Qk6{LKyUy7RN9!J=_-(z%3@lnn#yrC1a{F+Pk#@#7Pk`@j>uOFQ6ocQE+yY zdOw;n+OGAthM$az<||Dt>TPfQ;u$n)#;lpyb2FyT&4`bPfQy8H$CCqW|5f?vk%M1- zf4&;nu`@{bUNMUlZ{rs;So`h+K}*s#N;~yv_vP>QaK@!W2pi^HsQR1c59WiWeudb&w|+*DYyB1ySw}P zV5Gsn)Q7p$aCjd>XrJK`6X!q;cFx3z5MOMBJY;m__*BedQpZPzg+xV1X|)sL<3mCd z62fjnCyfeENC<~n&~1lKH3&{4Fyk2EBs82lbjZikPH18Kir2LW8V?^INFvnA*2{lD z&eGV})FigHUWH1vSvC|3jI$0YO7@yJR}?d&qESCD(2!caDk5Ualv*emfKYCPmx~nY z<%LjBF+0TnG6{Cjc#W+iZ#pyLfVWh)K4T=t#MQS%bOO0F!lb#Rx&6P-+AX z{^Ehxm7JwxVWrK11Ci%vl9@BlM;?&4aVA&SDYCOkwq8&6NU2F9f|-w(?#vn{^4OZx zE=gg$vijq@?)sSh4~vGZ?j1TzI(rTyUJ3UkB_&OpyC8qwGz_TvqLSrBkVh#*l+ z_+3Z}!JAq|9tg{C{d~JiH2Uok<;(-E`4Ra#yT~QN!SYBWjG7}4K3JtS<&C%<~5Xuv@lWSUfLp^#g>t7jxI6iz(>g&E(@m&u7ycJjemBKH^`EAWLo9}3&Cb9 z_wTPBG6fyc4A9&rbV zK(MFnvGVfr&uydF5Of5v({@3~J4U6C^hVFy=xT#w%BFT}QO0 zIGVXdk{dze26ZzZ?6X0g28wE9qf|Ifq80QNI)_F&k!Dm3K*>M)h9JVS-)4Hg? zI9L(Km>IGe%*b0pK?YC={vn|-7RS!S)p*quqj~AF@%w`Z5B{swCL(PnTS{L~{t1C> zt=iK|sJ#@NHmm4PJbaSzGC0FM#l7R(d+uv9zUT87#2KgL|{b3AUnmCU(f&S z9vnRnYZH8J0i)#`WQWzFaDV@ClT!Tkju8?2_p@sFtX3TyKhkvOy#z2Qv)iV(6Je)0 zOSP-2>f>G8PZ(^P8!nwXec@b9rMcdcP!r^m#?9!<(>P;%*Y8P#K(6mS~^PYDpSbhuaXR$+>xH>FF~Qfp=>#31hn7?AVNguOTainHiA(gN{J73@rmLVBU!w_v+Qq za6NbSMCptA@i2N!>>9T+J1Xi%&;c<{+5WjNSsyUwGb zsp;fqu!>v1Zfa?Wi!(a82akgQ?e;)-C#EQkdAg2(3&Nl))Is{DPEAcspBD7ffiX_GX&eF!<&UbraO!?$Jqq4%+(uc$b7=nb}RCCL}^ z6>l6mR-tEmMx7@H+m%EeAFtjK6}5xJ>-El88_!qnd|}h8;LTD}Qc_dLv{de5xiUh^ zbA=pGFK%~mc=1I&UAa=CtBs?$uFkqFDyD z#mmsssM{L{wIbb;n5Y(^ol#~`0zu_Q3I$E zlevImt?`hQ0yqpi4Wl9Q7{cUdJ4guhEko?_Xo3NB?urXd3K`_RvWgKSn zXC2+QuUJRiwWfLUQ^q?y1p-`C zVesPI2ePr0I(u$z?j#tFYZJIQIG8z2B%kJw2UNSFakZTCS?qwfkUCBf&id zaKlKv|C0G^;*qy_@zTZ%KiFv}-akM8(fkGTX5KeF4t@Do@*DcpdYzV3=?6aja%uTd zh|y0R*s-G~7L4`0vvKHk+3H~_@JM4JTyD&E|Ga)VWq2#d0AfG^=pqQ+DEZyd3 z#iVLuVq&zZh`bzBU#=Bw|?A?J*Qh;#)8aDn-~{2F%1_Je8pM-ykqmuva+3< zckGA@HnQd6%HNFE{+Q+YTN{6?#QjlYa9o^RH^FeZ>&h)JF_D5%A@3_}f&@0p_568_ z=Cl2uH4P3O+*EhAs_MHYdskQcChG66ce>y+E_s}A;bchQ0H@Z*RwvKlAyREmL}>@r z*M9WImi6m5ZrZl%gBsDx&)dmu?6l0;56sHSNQriDtv-FO*v`$(t@VIm#hV9Doof^X z^H#ogtB$Cyp8cP{Z(qB7Ii{}?-r6%hp4+18Vj*iUhkuNvsB^iOjzNw(<)%%l;isHp_;>G zha3H1Mjhhkd2t&?s@H$^Ok^wGl2Y!o@N5tL2v;x&MA%B;!w9~^nL zUE>1n+FJQ+G&A>BJX`5Ihx0g!Ilh*YDrxtmhx|!NYhf$8K<7ype}C;n3>g@RgwsOTX`RuV|H<*{%w!UoXCt;!wmUb5tF$J$;(d2N~4M3T3q)UswdVaGdI zDG-~gMB*A)ot9RGUad7v7mv9FxEzBPu}nKT6%HVn@B${x0nyo z)KOOmG>8|jQpi{UW)oS&M^72q~TsEHQ*~hY7SLb_wmKJG@0egF#oDmwm zW`j1+*}$9QdX?zuDc04A*d&{XZEFZD1+8geu{lMI84~4Zqa=7%ny?pTRFtaaHnddQM$Hu=&PB9k&pn@NmeoEl>735~O^%|D(qaAlGFh~A39)UG?l+K2>p*b4W2=bd<29ARE=UNlw1 zYe--v(rHDUkVrVgkCfX5lz{s*V&o4$$ln=J$etV=i@|pT$sl_=`-ze`4A#R~mqTEu zND2>6O2E*z4wD2X16zmd5~}XbyXYjqbz_IMlnsh6=zFu%3vUSl!#H%Hd2M1PmGoB; zFK4mg?Af#DzWbrN`ix(s7Q&y=gIpoM>({MwcB%E5w8za|g+(>Zu<)GRJgn1YrA!zd zl#v!02*ccN9lAb6A$+@D zrA5b3Ni;qKsf!^OFAiTEBS|pD#lu^_Fl+3H%fD!)6x(^gMfNjNc)w#UVln*-V(5NGRpdVeo4q z(lj~^qoubnn217xkcjKa!W4veVgC?O$P_XWW((N}=L@+A*9$Ml`{B2PA> z*6lAJPmk~c|9jmGoL)Rc+da1J7UjeK}2pvwG=`sQcQ zL4HOn@iW?wpV3DAY)do`J&>165^p|42~h@=R3Iu%96~kzZ6K4NC~F+v*pdhmLlWU| zAdf5om$w>ZV;d%el-QybeT5-{pWu(yOF`RZ3$w9CNQF73s<`7g4>BE$TgO@(bq?oc zgv4z}E20i@=SQZ*trfBnbGI;?g^2HmvlJinTv%#a5%&U~dYf>EbEdJDeJJ>tN^u%Y zBI9y++6Utb@Z|GC@-=ItoBZ)~ zS=#vT&5y-5PM5yx$+w;3nD5PJbKaKt&#o=+&2j$B*_;nMoBunf!+JFD)tz79zVr92 zZ}jG8&eNQZ<+nWFaLXs{*DStqoeVRh8Tc`Z(^WdsTPZZZXSl+C=6mVx`XR%&>-~C@ z*7ef)Gw-@U0ck&s>jlCBTt6Z_g6llksR+nx zA+GbWzq)`t7vZ`9s&@kNUP4sJ|1s$DFBO&&HQ*>AN)C$+pn=CN;ttLOYIV39Kn8*v zW?Z5`2H{YUaBxowG8Qz<7Fw!F#DUyP9>J4*915}+he#eHMF@2`9FZRUnLwOag6nly z`sxD-`$jx@1w5BCR-`^8O7Pg9K$Z*DMA8pBrk~;Z3vAwK1)i-6A;j|tzlHGFigV%~ zAe#7I2BmAexwXvKQqdH@|j)YOL#CD!26N09zBn#L15IssNo zE;NBoCK{SbQ;CvJ1OM(pGiU~}r&%-$VK&VsPSCP_0G!uD^dUUSh4@;5ecVw}ng^Mn zBV9-rBF-YZ2u})V0j`T^5uQImpTM<&8eqp!Op9^-Bvi=z(3Ny0-abX2BF^-$^so5p z8Tt&~K1-iP`fKPKK(dam1Nwo>^b`6C;vb<$@ceW7IpUn4 zCy@S^^h-QBO-}=oZ|S!Pzo*{=!t?Yzu4`#6uIp$W;QyKa4EXD5J)ZnRe*yfzVy6KG zZJ-T^*+iRgeVJZHoMzgL>lWIA>p$opNdGFmiX4qtIDpLxSad5ki6@?H_79x460Csn zYC%o<3f6)(-r5K@#6!>scDQyF91->r`VbevS#ZX+yWok?OYlMpgTXsXea43I?Zj2M zTS!HJWIczXA7>|!Wjc|cr zMU>d;z-3Fb2Sqw&ppJa@P^|D{hu|okuOABAag-{eTSzi_Z^=Rr zd5+BBeIT3nfd@GD&j#NAlgy#zw46N1aeW@g?1$*rz~dZx26&vyF?a!R_h<45$I*ox zJM%eSE&?u^NCC&TLcvzhki{I&9^)9c6d2|~9_P6A1jid4#}Ylq5(7#uk||*uLA_Y0 z;-M7})jYK3p$!jhd8px`9S`k!=)glq9`@m(6Aztvh*>^T?#n}09`@s*8xQ;Q(4B?A zeijySewUJb&SQy0&(9lKDBQ)v6#m44PGF-QO)yo3dDA<2n8?G4JWS%@BpxR7a54)q zd&O7Cg{4=*ub1)gDITt3A$pq(14?UKCnn(y3lKf64evxli(#Q>6Ho z&)}t-wEbt{+WgP>Q$}AH{vr#-5{2_`0YZ z-IOI=u=6$lmU$ss+k1=gocA?39!rVk+x{Dq1zw}D{k|7hQ@W39iFe({`tN;XhM(i8 zOrOj+#qr+UXV6w{cq|vk9PhuObpK7T^rk+TcEO+dU9a6(dgl5tw_%5S^pUQ9H-=?Q zURU3-XQrw#Mg06H%VU{tS7kH5=jG|dmzZfLf7eaf%->s{&2_?_b>?+C*2S0&mfy8s zGn&%NUsn_;vYRv*-I3o}j!&IqTb85qJ9gi(EM3LGGYfvOjeava&i-Ax`KHrjOBgM$ zJ4NHSH+tXIH~-0JW}LKyp{si5ILP=5&`a~r^4k3Dzx`~g9+U9j4&$wY&rCnMOV8YX zZD-IQJ{ESOL*0GDlWp@53;NdvFB`k%e={uRcjo){bN4j9J}zWwrAC2utH+K>;5d|-MtKfeQ7RR z=WBDSJ;}!$_j>1Bl@5pVPvH8E+u?f9|7YJ_t3foTq)Ui;I5wKUHRCJ0=hrKk{1ew% z<`^B%?A;B2-SH1M{IKf?UE@vHJP?DstN84hnWyUNJE`S*zT>jWsq{xD7Pi_Wv?1!FZF^x#BvE z0UhKNQcP1*=P%^vS=Z@rtk22+U47jnTGHvm?&|$|X_@;QOR94`Gfn7}%{8~?a&$gl z6H$K974CNzx988V9mg%}gUgrL9F6_VXX&mzG|%6%+8fKll_`6+`xZXSfWjiRzdn}T?1()6MShpbM z|IQ@Ftn3KanAOt#|3CT}9n05#XGurfpZ?#xvZP_)7dQNwQdp+Z+wcEup1sZI&&I#G zvi;dy|NFn|>G<$xc=%gU`p)gv`Tkbq(IE{1HU}aJ#j`NTy+;u(j&Rr>+(m9DcjM5) z0$~w}CXd4QU^;9MJ|*|VhTu1NZ*GFE?kV`FXn_SmD=Y-Qf$s5CxT48`u5mEaG^U|+ z2K4;L&x-iX#S^{W=;8o&FLU4{iPx^iF8=|CLUJ2LE$( zGJPILD*YSu_NUT~6lU|#-G3Wt{~bpT-9yXheCR5_51r-@pqXC)UFGBSF=*a@iL}1L z@fUguTK6mIcQ~Gf=KXnC13>4Vz5u=DUy$yv^f&qvbnBaudJA;vH`A+v7k!m!Cex3E zBB6*L5|&|m;g5xyB&S z|Mzdij@ygab9)g7u1)R;|5rO;LBe#(onSfgA+)0_aCpLkM($U|j>Wikl<(EVZSFpLHeANXY-P6pCI*q!*$AUcK&hUfON zWH|5&b;vDIe7Pmc5N?U$N2k!c$xyBh?*uHG3Tu^n>AfU?PN&mhdB8N|HC!`Z1ALtY zn{#HNV#O^~D8pS`19xHPv4AeXSIkaD;C3n+Zl~hN?NqF}or(j)X{5=lRVb`g;I-Pq zTE!YTUIGZ2%?bskuRxm2a>beJ;ycn+bQPX6`xR?o|7zsRELWV+51xY^33>u*<8SnD z2$?O*VEQ6`3H7#tZXiQ|y_@i4Gu=$w(L-LtHM48+q_5N0alM^xN3Aj&7ZdBhC-> z2h<+3t+D2|H5zVPZgb-bTL@cN z)xZlUtZnRIe=}0B7wn0T;2=1_B8S=Hcye2uA%c_OMBKQ&gs0#F7g!^NenNl5VYU;V z0uJOdKo~%Va7!HzZcX9IXcigBXcj`yEQHK1$A#PFSaZ7^TiGrL_A?|vctdytmO9Kv zN5icz92pgZ&4~bSvdmk97if+Hk?|yb80n;Q>#Eu ztwK1p3M5y^A7nJARvw&M4J9V3AR|Gu+<~3U4sIxA)XJYzD^E_XhH+{&fHIpn4QP`V zbgJDl&YROCdrpsrb9ywG)1yFcC1=AebttDzgSf36<&?;oQzDVhrgPAXn8h6B^ho5C zNaU1A#VL_9r$i#BM9!QBskp_Qvjq)u<}_#|r$K6rj(c{Dk2d77Y zoF0wh^k}37JsKt3>m8&A@fEY$^WgMo0H;UpoE~{|dgK9mbQJCN8K}}oPL)DrJHF%e z3p`;|X(*>k9<-94M9fp5Mfc-dC^59g-hEt_5ZV%|e=}`cuM*}%M^5FEyo71C#oE~|A9`y%hVRnKZ zoCf)E8Z?yCAj)lP#|TM667k|TgD%`=(3jf`j^H+fzTD>4fm_=Kb8Fka+}bvnTif>K z*0#ai+O{vZwhiXiwtcy^Z7{dC?aS?K`*1s3CrrV7yl@_HGRs8f)y7`O7-R<6X951v~baMFO2@+HBbt_kp|f;}~`dCLlT zDBT3dmu6oxq1@LD)(`kM6ue!3a9E?rIC3YshdclZ{5bgw_66IFeLPqkd);$g?;mMG8ZF*4t{Ibk*%0hNck$k zg$@9ZI|lQPNf<}&BM*YZ)x+KAi}29;cR3{$xKuCjz+vz=lT0$nJUD@5`z)-7?elIq zjum*-fn+%N>{#&A_mhXfC9~~)U&5&JjvPk~-qxE0VjPQuL^+G(fR8SL-15oBaNz@D)~p#f z9nPA+K%716ro-&qhv$nqHy_TonL|!aE=1Z)Q+)mT!Js68h$j{)8@1aBK2=Jqr4}%l^8Z*j|!MS|`jt$%zc(r5T z(ZHW^iBBbFizgXZ1`cC}FjJT%%o83Ga@stDJSr3l%Y`R}6~bS@FKiHA7CsS<2*-sl zkoWB<`Mnrhi!l>u29G}k@`Wt$-w%T0ehwV(^WbseI|6*|D|8e1TFkM*zwQ8MT8cUI zZt$l6ud%ZUt*eOQ_?`3aeO0VMN=%A|B&}&|Y>6?ZsaCCN)DWZA7~Awqg4LMB&}c2C z4I&|lrJ}Zlc2RcXXqRuD1k(uD*RfB(53?<1jv%eqnanp*;v{jQ;oxopuhrtpDaQi*CDs_hGNwMT~{V?HOw#Z?o<82>BL!);5v1 zTZe5X-)cJu*qpN`tkaswpS0)fQSxo}ytR-&WiQxcD7&aRd@JedbF1pHCZsu)( zs4_45oSEVb-IJAxVaE(=Pl%@hNxM9rkYoo2NL#xrWERzOX-UfgML^7YuB2TfAi5z@ zKSClOYSwp?(y`_F+XX{9O-Mvog6DhbJ*^owSM>^ZM(Mt&Ucss^Uelo;sYXl4N}g9y z0e50m9RmrmvqRSYdY(FDWOz5QyZ@$kGx!Ieg9SHTKc5T4j4@8(;#gp{H&M&uop_a4 zB7@e;=bT3>>`!=qWS{-T%+C?AM;wa(I7lh4`?@AL#)t+fS+udIrrAjs_+RtC@qZ5% zT?Oa$cGu$uI7OdyUvS#I%8Bi^RdIspLB5lA&aT+JE!bkNhIk#lxslvtZYFm*H=p~1 zH&|uZ7{1Cfi+9rDgDmDf6P^M4t_1rK-=rhTPBGn3I0SaB1na{;=^XZdHWrl{1D88r zf>Z{UJK?=ino)LvVl^S}_e}ATQl7V#@bs4O5Yq{s!*;BQr({;y_m-|f4|?j=aSNW+!hMM7#mc&s@F-7v z7^WpzF|H-SMLVC+Do<%oQ`S4Jg5>b5>ZI4mnY<4@J={FF;j?`e!t3x08(Wt8##%wp``4x{hrO><8 zWp@=W`FACGGSgoEFN0}~3X_JZ!vY{?D#%w!i}G}y=6s9fFCOJ-%eTH|6l60=dEAR} z=I*Dqmb=HLltcEB<5V7VC{HE1Xq7bj4Uda{l6m_($ z!@F=Fr|=d$C9PZ!q8qlrwG}(Cmb3YGC_2zw8<>+Wd<@-qQa0fOc?loDEBGUtIk~@$ zSL7Y84txP0;i-_V_8PvKPw}RF&h-YT`t#7vV6`2@yYUU41%1OdhM)H=5tDv*i}((T z))lKkT5&Bas|{RhNwU?`DNhl08sA}+wIzsTBwDRi*<8}FS{*)$GFldGDqWj!iz@HO z;IKsEJKhBamYQsEZ-=8%xef#uU06iX8dSO6_U0X}Y+2X^`??AZe`otxwsC41>uEI- z{SHcJiiW_B504zx5Z^s|@Q}Ng_F_MhJY#BPYWS`)o;8#Q)@4#=4)k|EF-_!dXXRD4 XH!I(5-)H4}a`nE + @Inject + lateinit var settings: KotatsuAppSettings + override fun onCreate() { super.onCreate() MMKV.initialize(this) diff --git a/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt index fb31be8..d018e00 100644 --- a/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt +++ b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import org.xtimms.shiki.ui.theme.SEED +import org.xtimms.tokusho.ui.theme.SEED import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.core.prefs.DarkThemePreference import org.xtimms.tokusho.core.prefs.paletteStyles diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt index 487bdcb..d3e6b9d 100644 --- a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt +++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -39,6 +40,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController @@ -58,6 +60,9 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val isReady: MutableState = mutableStateOf(false) + private val isDone: MutableState = mutableStateOf(false) + @Inject lateinit var coil: ImageLoader @@ -65,6 +70,7 @@ class MainActivity : ComponentActivity() { lateinit var loggers: Set<@JvmSuppressWildcards FileLogger> override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen().setKeepOnScreenCondition { !isDone.value } enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -77,18 +83,26 @@ class MainActivity : ComponentActivity() { val navController = rememberNavController() val windowSizeClass = calculateWindowSizeClass(this) val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact - SettingsProvider { - TokushoTheme( - darkTheme = LocalDarkTheme.current.isDarkTheme(), - isDynamicColorEnabled = LocalDynamicColorSwitch.current, - isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, - ) { - MainView( - coil = coil, - loggers = loggers, - isCompactScreen = isCompactScreen, - navController = navController - ) + LaunchedEffect(Unit) { + isReady.value = true + } + if (isReady.value) { + SettingsProvider { + TokushoTheme( + darkTheme = LocalDarkTheme.current.isDarkTheme(), + isDynamicColorEnabled = LocalDynamicColorSwitch.current, + isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, + ) { + MainView( + coil = coil, + loggers = loggers, + isCompactScreen = isCompactScreen, + navController = navController + ) + LaunchedEffect(Unit) { + isDone.value = true + } + } } } } diff --git a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt index 9652aff..43feba9 100644 --- a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt +++ b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt @@ -14,6 +14,9 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import okhttp3.OkHttpClient import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.xtimms.tokusho.core.cache.CacheDir @@ -21,11 +24,13 @@ import org.xtimms.tokusho.core.cache.ContentCache import org.xtimms.tokusho.core.cache.MemoryContentCache import org.xtimms.tokusho.core.cache.StubContentCache import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.model.LocalManga import org.xtimms.tokusho.core.network.MangaHttpClient import org.xtimms.tokusho.core.os.NetworkState import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl import org.xtimms.tokusho.core.parser.MangaRepository import org.xtimms.tokusho.core.parser.favicon.FaviconFetcher +import org.xtimms.tokusho.core.parser.local.LocalStorageChanges import org.xtimms.tokusho.utils.CoilImageGetter import org.xtimms.tokusho.utils.system.connectivityManager import org.xtimms.tokusho.utils.system.isLowRamDevice @@ -98,6 +103,16 @@ interface TokushoModule { } } + @Provides + @Singleton + @LocalStorageChanges + fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow = MutableSharedFlow() + + @Provides + @LocalStorageChanges + fun provideLocalStorageChangesFlow( + @LocalStorageChanges flow: MutableSharedFlow, + ): SharedFlow = flow.asSharedFlow() } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt index 599fe13..e3c6156 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt @@ -4,15 +4,18 @@ import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import coil.ImageLoader import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import org.xtimms.tokusho.R +import org.xtimms.tokusho.utils.composable.rememberResourceBitmapPainter @Composable fun AsyncImageImpl( @@ -38,14 +41,12 @@ fun AsyncImageImpl( ) else AsyncImage( imageLoader = coil, - model = model?.takeUnless { it == "" }, - contentDescription = contentDescription, + model = model, + placeholder = ColorPainter(Color(0x1F888888)), + error = rememberResourceBitmapPainter(id = R.drawable.cover_error), + fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading), modifier = modifier, - transform = transform, - onState = onState, - alignment = alignment, contentScale = contentScale, - colorFilter = colorFilter, - filterQuality = filterQuality + contentDescription = contentDescription ) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt index af32d2c..23fba18 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -4,6 +4,8 @@ import android.graphics.Path import android.view.animation.PathInterpolator import androidx.compose.animation.core.Easing import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable @@ -21,8 +23,13 @@ import org.xtimms.tokusho.core.motion.materialSharedAxisXIn import org.xtimms.tokusho.core.motion.materialSharedAxisXOut import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION import org.xtimms.tokusho.sections.details.DetailsView +import org.xtimms.tokusho.sections.details.FULL_POSTER_DESTINATION +import org.xtimms.tokusho.sections.details.FullImageView import org.xtimms.tokusho.sections.details.MANGA_ID_ARGUMENT +import org.xtimms.tokusho.sections.details.PICTURES_ARGUMENT import org.xtimms.tokusho.sections.explore.ExploreView +import org.xtimms.tokusho.sections.feed.FEED_DESTINATION +import org.xtimms.tokusho.sections.feed.FeedView import org.xtimms.tokusho.sections.history.HistoryView import org.xtimms.tokusho.sections.list.LIST_DESTINATION import org.xtimms.tokusho.sections.list.MangaListView @@ -33,6 +40,13 @@ import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION import org.xtimms.tokusho.sections.settings.SettingsView import org.xtimms.tokusho.sections.settings.about.ABOUT_DESTINATION import org.xtimms.tokusho.sections.settings.about.AboutView +import org.xtimms.tokusho.sections.settings.about.LICENSES_DESTINATION +import org.xtimms.tokusho.sections.settings.about.LICENSE_CONTENT_ARGUMENT +import org.xtimms.tokusho.sections.settings.about.LICENSE_DESTINATION +import org.xtimms.tokusho.sections.settings.about.LICENSE_NAME_ARGUMENT +import org.xtimms.tokusho.sections.settings.about.LICENSE_WEBSITE_ARGUMENT +import org.xtimms.tokusho.sections.settings.about.LicenseView +import org.xtimms.tokusho.sections.settings.about.OpenSourceLicensesView import org.xtimms.tokusho.sections.settings.about.UPDATES_DESTINATION import org.xtimms.tokusho.sections.settings.about.UpdateView import org.xtimms.tokusho.sections.settings.advanced.ADVANCED_DESTINATION @@ -43,16 +57,27 @@ import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION import org.xtimms.tokusho.sections.settings.appearance.LanguagesView +import org.xtimms.tokusho.sections.settings.backup.BACKUP_RESTORE_DESTINATION +import org.xtimms.tokusho.sections.settings.backup.BackupRestoreView +import org.xtimms.tokusho.sections.settings.backup.RESTORE_ARGUMENT +import org.xtimms.tokusho.sections.settings.backup.RESTORE_DESTINATION +import org.xtimms.tokusho.sections.settings.backup.RestoreItemsView +import org.xtimms.tokusho.sections.settings.network.NETWORK_DESTINATION +import org.xtimms.tokusho.sections.settings.network.NetworkView import org.xtimms.tokusho.sections.settings.shelf.SHELF_SETTINGS_DESTINATION import org.xtimms.tokusho.sections.settings.shelf.ShelfSettingsView import org.xtimms.tokusho.sections.settings.shelf.categories.CATEGORIES_DESTINATION import org.xtimms.tokusho.sections.settings.shelf.categories.CategoriesView +import org.xtimms.tokusho.sections.settings.sources.SOURCES_DESTINATION +import org.xtimms.tokusho.sections.settings.sources.SourcesView +import org.xtimms.tokusho.sections.settings.sources.catalog.CATALOG_DESTINATION +import org.xtimms.tokusho.sections.settings.sources.catalog.SourcesCatalogView import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION import org.xtimms.tokusho.sections.settings.storage.StorageView -import org.xtimms.tokusho.sections.shelf.ShelfMap import org.xtimms.tokusho.sections.shelf.ShelfView import org.xtimms.tokusho.sections.stats.STATS_DESTINATION import org.xtimms.tokusho.sections.stats.StatsView +import org.xtimms.tokusho.utils.StringArrayNavType import org.xtimms.tokusho.utils.lang.removeFirstAndLast const val DURATION_ENTER = 400 @@ -77,6 +102,15 @@ fun Navigation( val navigateBack: () -> Unit = { navController.popBackStack() } + val navigateToLicense: (String, String?, String?) -> Unit = { name, website, content -> + navController.navigate( + LICENSE_DESTINATION + .replace(LICENSE_NAME_ARGUMENT, name) + .replace(LICENSE_WEBSITE_ARGUMENT, website.orEmpty()) + .replace(LICENSE_CONTENT_ARGUMENT, content ?: "No license text") + ) + } + val path = Path().apply { moveTo(0f, 0f) cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) @@ -102,14 +136,18 @@ fun Navigation( ) { composable(BottomNavDestination.Shelf.route) { - val library: ShelfMap = emptyMap() ShelfView( - currentPage = { 0 }, + coil = coil, + currentPage = { 2 }, showPageTabs = true, - getNumberOfMangaForCategory = { 2 }, - getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() }, padding = padding, topBarHeightPx = topBarHeightPx, + navigateToDetails = { + navController.navigate( + DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString()) + ) + }, + onRefresh = { true } ) } @@ -142,12 +180,26 @@ fun Navigation( ) } + composable(FEED_DESTINATION) { + FeedView( + navigateBack = navigateBack, + navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) } + ) + } + composable(SETTINGS_DESTINATION) { SettingsView( navigateBack = navigateBack, navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) }, navigateToAbout = { navController.navigate(ABOUT_DESTINATION) }, navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) }, + navigateToBackupRestoreSettings = { + navController.navigate( + BACKUP_RESTORE_DESTINATION + ) + }, + navigateToMangaSources = { navController.navigate(SOURCES_DESTINATION) }, + navigateToNetwork = { navController.navigate(NETWORK_DESTINATION) }, navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) }, navigateToStorage = { navController.navigate(STORAGE_DESTINATION) } ) @@ -155,7 +207,6 @@ fun Navigation( composable(APPEARANCE_DESTINATION) { AppearanceView( - coil = coil, navigateBack = navigateBack, navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) }, navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) } @@ -174,6 +225,43 @@ fun Navigation( ) } + composable(SOURCES_DESTINATION) { + SourcesView( + navigateBack = navigateBack, + navigateToSourcesCatalog = { navController.navigate(CATALOG_DESTINATION) }, + navigateToSourcesManagement = { /*TODO*/ } + ) + } + + composable(CATALOG_DESTINATION) { + SourcesCatalogView( + navigateBack = navigateBack, + ) + } + + composable(BACKUP_RESTORE_DESTINATION) { + BackupRestoreView( + navigateBack = navigateBack, + navigateToRestoreScreen = { + navController.navigate(RESTORE_DESTINATION.replace(RESTORE_ARGUMENT, it)) + } + ) + } + + composable( + route = RESTORE_DESTINATION, + arguments = listOf( + navArgument(RESTORE_ARGUMENT.removeFirstAndLast()) { + type = NavType.StringType + } + ) + ) { navEntry -> + RestoreItemsView( + uri = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) ?: "", + navigateBack = navigateBack + ) + } + composable(SHELF_SETTINGS_DESTINATION) { ShelfSettingsView( navigateBack = navigateBack, @@ -187,6 +275,12 @@ fun Navigation( ) } + composable(NETWORK_DESTINATION) { + NetworkView( + navigateBack = navigateBack, + ) + } + composable(STORAGE_DESTINATION) { StorageView( navigateBack = navigateBack, @@ -233,10 +327,43 @@ fun Navigation( composable(ABOUT_DESTINATION) { AboutView( navigateBack = navigateBack, + navigateToLicensesPage = { navController.navigate(LICENSES_DESTINATION) }, navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) } ) } + composable(LICENSES_DESTINATION) { + OpenSourceLicensesView( + navigateBack = navigateBack, + navigateToLicensePage = navigateToLicense + ) + } + + composable( + route = LICENSE_DESTINATION, + arguments = listOf( + navArgument(LICENSE_NAME_ARGUMENT.removeFirstAndLast()) { + type = NavType.StringType + }, + navArgument(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast()) { + type = NavType.StringType + }, + navArgument(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast()) { + type = NavType.StringType + } + ) + ) { navEntry -> + LicenseView( + name = navEntry.arguments?.getString(LICENSE_NAME_ARGUMENT.removeFirstAndLast()) + .orEmpty(), + website = navEntry.arguments?.getString(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast()) + .orEmpty(), + license = navEntry.arguments?.getString(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast()) + ?: "No license text", + navigateBack = navigateBack + ) + } + composable(UPDATES_DESTINATION) { UpdateView( navigateBack = navigateBack, @@ -249,13 +376,42 @@ fun Navigation( navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) { type = NavType.LongType } - ) + ), ) { navEntry -> DetailsView( coil = coil, + mangaId = navEntry.arguments?.getLong(MANGA_ID_ARGUMENT.removeFirstAndLast()) ?: 0L, navigateBack = navigateBack, + navigateToFullImage = { pictures -> + navController.navigate( + FULL_POSTER_DESTINATION.replace(PICTURES_ARGUMENT, pictures) + ) + }, + navigateToDetails = { + navController.navigate( + DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString()) + ) + }, + navigateToSource = { + navController.navigate( + LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name) + ) + } ) } - } + composable( + FULL_POSTER_DESTINATION, + arguments = listOf( + navArgument(PICTURES_ARGUMENT.removeFirstAndLast()) { type = StringArrayNavType } + ), + ) { navEntry -> + FullImageView( + coil = coil, + pictures = navEntry.arguments?.getStringArray(PICTURES_ARGUMENT.removeFirstAndLast()) + ?: emptyArray(), + navigateBack = navigateBack + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt new file mode 100644 index 0000000..4627c75 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/KotatsuBaseViewModel.kt @@ -0,0 +1,79 @@ +package org.xtimms.tokusho.core.base.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.xtimms.tokusho.utils.lang.EventFlow +import org.xtimms.tokusho.utils.lang.MutableEventFlow +import org.xtimms.tokusho.utils.lang.call +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException + +abstract class KotatsuBaseViewModel : ViewModel() { + + @JvmField + protected val loadingCounter = MutableStateFlow(0) + + @JvmField + protected val errorEvent = MutableEventFlow() + + val onError: EventFlow + get() = errorEvent + + val isLoading: StateFlow = loadingCounter.map { it > 0 } + .stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0) + + protected fun launchJob( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job = viewModelScope.launch(context + createErrorHandler(), start, block) + + protected fun launchLoadingJob( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit + ): Job = viewModelScope.launch(context + createErrorHandler(), start) { + loadingCounter.increment() + try { + block() + } finally { + loadingCounter.decrement() + } + } + + protected fun Flow.withLoading() = onStart { + loadingCounter.increment() + }.onCompletion { + loadingCounter.decrement() + } + + protected fun Flow.withErrorHandling() = catch { error -> + errorEvent.call(error) + } + + protected fun MutableStateFlow.increment() = update { it + 1 } + + protected fun MutableStateFlow.decrement() = update { it - 1 } + + private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> + if (throwable !is CancellationException) { + errorEvent.call(throwable) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt new file mode 100644 index 0000000..e99d62e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/AnimatedButton.kt @@ -0,0 +1,114 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import org.xtimms.tokusho.ui.theme.TokushoTheme +import java.lang.Integer.MAX_VALUE +import kotlin.math.min + +enum class ButtonType { PRIMARY, SECONDARY, TERTIARY, DELETE } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AnimatedButton( + modifier: Modifier = Modifier, + type: ButtonType, + icon: ImageVector? = null, + onClick: (() -> Unit) = {}, + onLongClick: (() -> Unit) = {}, +) { + val localDensity = LocalDensity.current + var minSize by remember { mutableStateOf(MAX_VALUE.dp) } + var minSizeFloat by remember { mutableStateOf(MAX_VALUE.toFloat()) } + val interactionSource = remember { MutableInteractionSource() } + val isPressed = interactionSource.collectIsPressedAsState() + val radius = animateDpAsState(targetValue = if (isPressed.value) 12.dp else minSize / 2) + + val color = when (type) { + ButtonType.PRIMARY -> MaterialTheme.colorScheme.primaryContainer + ButtonType.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer + ButtonType.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer + ButtonType.DELETE -> MaterialTheme.colorScheme.errorContainer + } + + val contentColor = when (type) { + ButtonType.PRIMARY -> MaterialTheme.colorScheme.onPrimaryContainer + ButtonType.SECONDARY -> MaterialTheme.colorScheme.onSecondaryContainer + ButtonType.TERTIARY -> MaterialTheme.colorScheme.onTertiaryContainer + ButtonType.DELETE -> MaterialTheme.colorScheme.onErrorContainer + } + + Surface( + tonalElevation = 10.dp, + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { + minSize = with(localDensity) { min(it.size.height, it.size.width).toDp() } + minSizeFloat = min(it.size.height, it.size.width).toFloat() + } + .clip(RoundedCornerShape(radius.value)) + ) { + Box( + modifier = Modifier + .background(color = color) + .fillMaxSize() + .clip(RoundedCornerShape(radius.value)) + .combinedClickable( + interactionSource = interactionSource, + indication = ripple(), + onClick = { onClick.invoke() }, + onLongClick = { onLongClick.invoke() }, + ), + contentAlignment = Alignment.Center + ) { + if (icon !== null) { + Icon( + imageVector = icon, + tint = contentColor, + modifier = Modifier.size(min(minSize * 0.5f, 154.dp)), + contentDescription = null, + ) + } + } + } +} + +@Preview(name = "Icon") +@Composable +private fun PreviewWithIcon() { + TokushoTheme { + AnimatedButton( + type = ButtonType.PRIMARY, + icon = Icons.Outlined.Edit + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt b/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt new file mode 100644 index 0000000..6d6c4bc --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt @@ -0,0 +1,81 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FloatTweenSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import kotlinx.coroutines.launch +import org.xtimms.tokusho.core.components.shape.WavyShape +import org.xtimms.tokusho.utils.lang.clamp +import org.xtimms.tokusho.utils.material.HarmonizedColorPalette +import kotlin.math.ceil + +@Composable +fun BackgroundProgress( + color: Color, +) { + + val percentWithNewSpent = 0.3f + + val percentWithNewSpentAnimated = animateFloatAsState( + label = "percentWithNewSpentAnimated", + targetValue = percentWithNewSpent, + animationSpec = TweenSpec(300), + ).value + + val shift = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + fun anim() { + coroutineScope.launch { + shift.animateTo( + 1f, + animationSpec = FloatTweenSpec(4000, 0, LinearEasing) + ) + shift.snapTo(0f) + anim() + } + } + + anim() + } + + Box(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .background( + color.copy(alpha = 0.33f), + shape = WavyShape( + period = 30.dp, + amplitude = percentWithNewSpentAnimated.clamp(0.96f, 1f) * 2.dp, + shift = shift.value, + ), + ) + .fillMaxHeight() + .fillMaxWidth(percentWithNewSpentAnimated), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt index 710dfb4..d044b2d 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/DetailsToolbar.kt @@ -1,7 +1,25 @@ package org.xtimms.tokusho.core.components +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -9,7 +27,12 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -18,14 +41,29 @@ import androidx.compose.ui.unit.dp fun DetailsToolbar( title: String, titleAlphaProvider: () -> Float, - onBackClicked: () -> Unit, + navigateBack: () -> Unit, + navigateToWebBrowser: () -> Unit, modifier: Modifier = Modifier, backgroundAlphaProvider: () -> Float = titleAlphaProvider ) { + + var expanded by remember { mutableStateOf(false) } + + val padding by animateDpAsState( + targetValue = if (backgroundAlphaProvider() == 1f) 0.dp else 16.dp, + label = "padding", + ) + Column( - modifier = modifier, + modifier = modifier ) { TopAppBar( + navigationIcon = { + CircleBackIconButton( + modifier = Modifier.padding(start = padding), + onClick = navigateBack + ) + }, title = { Text( text = title, @@ -34,10 +72,44 @@ fun DetailsToolbar( color = LocalContentColor.current.copy(alpha = titleAlphaProvider()), ) }, - navigationIcon = { - BackIconButton( - onClick = onBackClicked - ) + actions = { + FilledTonalIconButton( + modifier = Modifier.padding(end = padding), + onClick = { expanded = true }, colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.outline, + disabledContentColor = MaterialTheme.colorScheme.outlineVariant + ) + ) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Share") }, + onClick = { /*TODO*/ }, + leadingIcon = { + Icon(imageVector = Icons.Default.Share, contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text("Download") }, + onClick = { /*TODO*/ }, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Download, contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text("Open in web browser") }, + onClick = { + navigateToWebBrowser() + expanded = false + }, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Language, contentDescription = null) + } + ) + } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt b/app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt new file mode 100644 index 0000000..dc8a953 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/DotSeparatorText.kt @@ -0,0 +1,25 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun DotSeparatorText( + modifier: Modifier = Modifier, +) { + Text( + text = " • ", + modifier = modifier, + ) +} + +@Composable +fun DotSeparatorNoSpaceText( + modifier: Modifier = Modifier, +) { + Text( + text = "•", + modifier = modifier, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt new file mode 100644 index 0000000..f5f3921 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/FloatingActionButton.kt @@ -0,0 +1,125 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +/** + * ExtendedFloatingActionButton with custom transition between collapsed/expanded state. + * + * @see androidx.compose.material3.ExtendedFloatingActionButton + */ +@Composable +fun ExtendedFloatingActionButton( + text: @Composable () -> Unit, + icon: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + expanded: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = MaterialTheme.shapes.large, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = contentColorFor(containerColor), + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), +) { + val minWidth by animateDpAsState( + targetValue = if (expanded) ExtendedFabMinimumWidth else FabContainerWidth, + label = "minWidth", + ) + FloatingActionButton( + modifier = modifier.sizeIn(minWidth = minWidth), + onClick = onClick, + interactionSource = interactionSource, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + elevation = elevation, + ) { + val startPadding by animateDpAsState( + targetValue = if (expanded) ExtendedFabIconSize / 2 else 0.dp, + label = "startPadding", + ) + val endPadding by animateDpAsState( + targetValue = if (expanded) ExtendedFabTextPadding else 0.dp, + label = "endPadding", + ) + + Row( + modifier = Modifier.padding(start = startPadding, end = endPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + AnimatedVisibility( + visible = expanded, + enter = ExtendedFabExpandAnimation, + exit = ExtendedFabCollapseAnimation, + ) { + Row { + Spacer(Modifier.width(ExtendedFabIconPadding)) + text() + } + } + } + } +} + +private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f) +private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) + +private val ExtendedFabMinimumWidth = 80.dp +private val ExtendedFabIconSize = 24.0.dp +private val ExtendedFabIconPadding = 12.dp +private val ExtendedFabTextPadding = 20.dp + +private val ExtendedFabCollapseAnimation = fadeOut( + animationSpec = tween( + durationMillis = 100, + easing = EasingLinearCubicBezier, + ), +) + shrinkHorizontally( + animationSpec = tween( + durationMillis = 500, + easing = EasingEmphasizedCubicBezier, + ), + shrinkTowards = Alignment.Start, +) + +private val ExtendedFabExpandAnimation = fadeIn( + animationSpec = tween( + durationMillis = 200, + delayMillis = 100, + easing = EasingLinearCubicBezier, + ), +) + expandHorizontally( + animationSpec = tween( + durationMillis = 500, + easing = EasingEmphasizedCubicBezier, + ), + expandFrom = Alignment.Start, +) + +private val FabContainerWidth = 56.0.dp \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt b/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt index 502aced..aab85f8 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/IconButtons.kt @@ -1,12 +1,19 @@ package org.xtimms.tokusho.core.components +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.OpenInBrowser +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import org.xtimms.tokusho.R @Composable @@ -21,6 +28,28 @@ fun BackIconButton( } } +@Composable +fun CircleBackIconButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + FilledTonalIconButton( + modifier = modifier, + onClick = onClick, + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.outline, + disabledContentColor = MaterialTheme.colorScheme.outlineVariant + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "arrow_back" + ) + } +} + @Composable fun ViewInBrowserButton( onClick: () -> Unit diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt index a0a9648..fbfc304 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaCover.kt @@ -18,7 +18,7 @@ import org.xtimms.tokusho.core.AsyncImageImpl enum class MangaCover(val ratio: Float) { Square(1f / 1f), - Book(10f / 16f), + Book(2f / 3f), ; @Composable diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt index 627ac1b..377207c 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/MangaGridItem.kt @@ -6,14 +6,17 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -33,25 +36,19 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.tokusho.core.AsyncImageImpl import org.xtimms.tokusho.ui.theme.TokushoTheme private const val GridSelectedCoverAlpha = 0.76f -/** - * Layout of grid list item with title overlaying the cover. - * Accepts null [title] for a cover-only view. - */ @Composable -fun MangaCompactGridItem( +fun MangaGridItem( coil: ImageLoader, - imageUrl: String, + manga: Manga, onClick: () -> Unit, onLongClick: () -> Unit, isSelected: Boolean = false, - title: String? = null, - onClickContinueReading: (() -> Unit)? = null, - coverAlpha: Float = 1f, ) { GridItemSelectable( isSelected = isSelected, @@ -71,12 +68,53 @@ fun MangaCompactGridItem( .clip(MaterialTheme.shapes.medium) .aspectRatio(10F / 16F), coil = coil, - model = imageUrl, + model = manga.largeCoverUrl ?: manga.coverUrl, contentDescription = null ) } Text( - text = title!!, + text = manga.title, + modifier = Modifier.padding(4.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = MaterialTheme.typography.titleSmall, + ) + } + } +} + +@Composable +fun MangaHorizontalItem( + coil: ImageLoader, + manga: Manga, + onClick: () -> Unit, + onLongClick: () -> Unit, + isSelected: Boolean = false, +) { + GridItemSelectable( + isSelected = isSelected, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier.width(IntrinsicSize.Min) + ) { + Column( + horizontalAlignment = Alignment.Start + ) { + Box { + AsyncImageImpl( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .clip(MaterialTheme.shapes.medium) + .aspectRatio(10F / 16F) + .height(156.dp), + coil = coil, + model = manga.largeCoverUrl ?: manga.coverUrl, + contentDescription = null + ) + } + Text( + text = manga.title, modifier = Modifier.padding(4.dp), overflow = TextOverflow.Ellipsis, maxLines = 2, @@ -206,18 +244,4 @@ private fun GridItemSelectable( private fun Modifier.selectedOutline( isSelected: Boolean, color: Color, -) = this then drawBehind { if (isSelected) drawRect(color = color) } - -@PreviewLightDark -@Composable -fun MangaGridItemPreview() { - TokushoTheme { - MangaCompactGridItem( - coil = ImageLoader(LocalContext.current), - imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg", - title = "Stub", - onClick = { }, - onLongClick = { } - ) - } -} \ No newline at end of file +) = this then drawBehind { if (isSelected) drawRect(color = color) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt index 281eb4c..e99cc19 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt @@ -65,14 +65,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.xtimms.shiki.ui.theme.FixedAccentColors +import org.xtimms.tokusho.ui.theme.FixedAccentColors import org.xtimms.tokusho.R import org.xtimms.tokusho.ui.monet.LocalTonalPalettes import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes import org.xtimms.tokusho.ui.theme.PreviewThemeLight import org.xtimms.tokusho.ui.theme.TokushoTheme import org.xtimms.tokusho.ui.theme.applyOpacity -import org.xtimms.tokusho.ui.theme.preferenceTitle import org.xtimms.tokusho.utils.FileSize private const val horizontal = 8 @@ -170,7 +169,6 @@ internal fun PreferenceItemTitle( modifier: Modifier = Modifier, text: String, maxLines: Int = 2, - style: TextStyle = preferenceTitle, enabled: Boolean, color: Color = MaterialTheme.colorScheme.onBackground, overflow: TextOverflow = TextOverflow.Ellipsis @@ -179,7 +177,6 @@ internal fun PreferenceItemTitle( modifier = modifier, text = text, maxLines = maxLines, - style = style, color = color.applyOpacity(enabled), overflow = overflow ) @@ -349,7 +346,6 @@ fun PreferenceSwitchWithContainer( Text( text = title, maxLines = 2, - style = preferenceTitle, color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface ) } diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt new file mode 100644 index 0000000..317be50 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/PullRefresh.kt @@ -0,0 +1,290 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.PullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.pow + +/** + * @param refreshing Whether the layout is currently refreshing + * @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed. + * @param enabled Whether the the layout should react to swipe gestures or not. + * @param indicatorPadding Content padding for the indicator, to inset the indicator in if required. + * @param content The content containing a vertically scrollable composable. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PullRefresh( + refreshing: Boolean, + enabled: () -> Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + indicatorPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit, +) { + val state = rememberPullToRefreshState( + isRefreshing = refreshing, + extraVerticalOffset = indicatorPadding.calculateTopPadding(), + enabled = enabled, + onRefresh = onRefresh, + ) + + Box(modifier.nestedScroll(state.nestedScrollConnection)) { + content() + + val contentPadding = remember(indicatorPadding) { + object : PaddingValues { + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = + indicatorPadding.calculateLeftPadding(layoutDirection) + + override fun calculateTopPadding(): Dp = 0.dp + + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = + indicatorPadding.calculateRightPadding(layoutDirection) + + override fun calculateBottomPadding(): Dp = + indicatorPadding.calculateBottomPadding() + } + } + PullToRefreshContainer( + state = state, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(contentPadding), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun rememberPullToRefreshState( + isRefreshing: Boolean, + extraVerticalOffset: Dp, + positionalThreshold: Dp = 64.dp, + enabled: () -> Boolean = { true }, + onRefresh: () -> Unit, +): PullToRefreshStateImpl { + val density = LocalDensity.current + val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() } + val positionalThresholdPx = with(density) { positionalThreshold.toPx() } + return rememberSaveable( + extraVerticalOffset, + positionalThresholdPx, + enabled, + onRefresh, + saver = PullToRefreshStateImpl.Saver( + extraVerticalOffset = extraVerticalOffsetPx, + positionalThreshold = positionalThresholdPx, + enabled = enabled, + onRefresh = onRefresh, + ), + ) { + PullToRefreshStateImpl( + initialRefreshing = isRefreshing, + extraVerticalOffset = extraVerticalOffsetPx, + positionalThreshold = positionalThresholdPx, + enabled = enabled, + onRefresh = onRefresh, + ) + }.also { + LaunchedEffect(isRefreshing) { + if (isRefreshing && !it.isRefreshing) { + it.startRefreshAnimated() + } else if (!isRefreshing && it.isRefreshing) { + it.endRefreshAnimated() + } + } + } +} + +/** + * Creates a [PullToRefreshState]. + * + * @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered + * @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state + * @param initialRefreshing The initial refreshing value of [PullToRefreshState] + * @param enabled a callback used to determine whether scroll events are to be handled by this + * @param onRefresh a callback to run when pull-to-refresh action is triggered by user + * [PullToRefreshState] + */ +@OptIn(ExperimentalMaterial3Api::class) +private class PullToRefreshStateImpl( + initialRefreshing: Boolean, + private val extraVerticalOffset: Float, + override val positionalThreshold: Float, + enabled: () -> Boolean, + private val onRefresh: () -> Unit, +) : PullToRefreshState { + + override val progress get() = adjustedDistancePulled / positionalThreshold + override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f) + + override var isRefreshing by mutableStateOf(initialRefreshing) + + private val refreshingVerticalOffset: Float + get() = positionalThreshold + extraVerticalOffset + + override fun startRefresh() { + isRefreshing = true + verticalOffset = refreshingVerticalOffset + } + + suspend fun startRefreshAnimated() { + isRefreshing = true + animateTo(refreshingVerticalOffset) + } + + override fun endRefresh() { + verticalOffset = 0f + isRefreshing = false + } + + suspend fun endRefreshAnimated() { + animateTo(0f) + isRefreshing = false + } + + override var nestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled() -> Offset.Zero + // Swiping up + source == NestedScrollSource.Drag && available.y < 0 -> { + consumeAvailableOffset(available) + } + else -> Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled() -> Offset.Zero + // Swiping down + source == NestedScrollSource.Drag && available.y > 0 -> { + consumeAvailableOffset(available) + } + else -> Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return Velocity(0f, onRelease(available.y)) + } + } + + /** Helper method for nested scroll connection */ + fun consumeAvailableOffset(available: Offset): Offset { + val y = if (isRefreshing) { + 0f + } else { + val newOffset = (distancePulled + available.y).coerceAtLeast(0f) + val dragConsumed = newOffset - distancePulled + distancePulled = newOffset + verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f)) + dragConsumed + } + return Offset(0f, y) + } + + /** Helper method for nested scroll connection. Calls onRefresh callback when triggered */ + suspend fun onRelease(velocity: Float): Float { + if (isRefreshing) return 0f // Already refreshing, do nothing + // Trigger refresh + if (adjustedDistancePulled > positionalThreshold) { + onRefresh() + startRefreshAnimated() + } else { + animateTo(0f) + } + + val consumed = when { + // We are flinging without having dragged the pull refresh (for example a fling inside + // a list) - don't consume + distancePulled == 0f -> 0f + // If the velocity is negative, the fling is upwards, and we don't want to prevent the + // the list from scrolling + velocity < 0f -> 0f + // We are showing the indicator, and the fling is downwards - consume everything + else -> velocity + } + distancePulled = 0f + return consumed + } + + suspend fun animateTo(offset: Float) { + animate(initialValue = verticalOffset, targetValue = offset) { value, _ -> + verticalOffset = value + } + } + + /** Provides custom vertical offset behavior for [PullToRefreshContainer] */ + fun calculateVerticalOffset(): Float = when { + // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. + adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled + else -> { + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + // The additional offset beyond the threshold. + val extraOffset = positionalThreshold * tensionPercent + positionalThreshold + extraOffset + } + } + + companion object { + /** The default [Saver] for [PullToRefreshStateImpl]. */ + fun Saver( + extraVerticalOffset: Float, + positionalThreshold: Float, + enabled: () -> Boolean, + onRefresh: () -> Unit, + ) = Saver( + save = { it.isRefreshing }, + restore = { isRefreshing -> + PullToRefreshStateImpl( + initialRefreshing = isRefreshing, + extraVerticalOffset = extraVerticalOffset, + positionalThreshold = positionalThreshold, + enabled = enabled, + onRefresh = onRefresh, + ) + }, + ) + } + + private var distancePulled by mutableFloatStateOf(0f) + private val adjustedDistancePulled: Float get() = distancePulled * 0.5f +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt new file mode 100644 index 0000000..a58f73c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ReadButton.kt @@ -0,0 +1,187 @@ +package org.xtimms.tokusho.core.components + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FloatTweenSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.xtimms.tokusho.ui.theme.TokushoTheme +import org.xtimms.tokusho.utils.material.combineColors +import org.xtimms.tokusho.utils.material.harmonize +import org.xtimms.tokusho.utils.material.toPalette + +@Composable +fun RowScope.ReadButton() { + + val shift = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + fun anim() { + coroutineScope.launch { + shift.animateTo( + 1f, + animationSpec = FloatTweenSpec(4000, 0, LinearEasing) + ) + shift.snapTo(0f) + anim() + } + } + + anim() + } + val percentWithNewSpentAnimated = animateFloatAsState( + label = "percentWithNewSpentAnimated", + targetValue = 0.3f, + animationSpec = TweenSpec(300), + ).value + + Card( + modifier = Modifier + .weight(1F) + .height(54.dp), + shape = CircleShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + onClick = { + // appViewModel.openSheet(PathState(WALLET_SHEET)) + } + ) { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.CenterEnd, + ) { + BackgroundProgress(MaterialTheme.colorScheme.primary) + Row( + modifier = Modifier + .fillMaxSize() + .drawWithLayer { + drawContent() + val leftOffset = size.width - 20.dp.toPx() + drawRect( + topLeft = Offset(leftOffset, 0f), + size = Size( + 20.dp.toPx(), + size.height, + ), + blendMode = BlendMode.SrcIn, + brush = Brush.horizontalGradient( + colors = listOf( + Color.Black, + Color.Black.copy(alpha = 0f), + ), + startX = leftOffset, + endX = leftOffset + 14.dp.toPx() + ) + ) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Spacer(modifier = Modifier.weight(1f)) + Text(text = "Continue reading", color = MaterialTheme.colorScheme.onPrimaryContainer) + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) { + with(drawContext.canvas.nativeCanvas) { + val checkPoint = saveLayer(null, null) + block() + restoreToCount(checkPoint) + } +} + +fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then( + Modifier.drawWithContent { + drawWithLayer { + block() + } + } +) + +@Preview(name = "The budget is almost completely spent") +@Composable +private fun Preview() { + TokushoTheme { + Row { + ReadButton() + } + } +} + +@Preview(name = "Budget half spent") +@Composable +private fun PreviewHalf() { + TokushoTheme { + Row { + ReadButton() + } + } +} + +@Preview(name = "Almost no budget") +@Composable +private fun PreviewFull() { + TokushoTheme { + Row { + ReadButton() + } + } +} + +@Preview(name = "Overspending budget") +@Composable +private fun PreviewOverspending() { + TokushoTheme { + Row { + ReadButton() + } + } +} + +@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun PreviewNightMode() { + TokushoTheme { + Row { + ReadButton() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt index 014f4a5..fb806ee 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt @@ -1,6 +1,7 @@ package org.xtimms.tokusho.core.components import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -21,6 +22,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll fun ScaffoldWithTopAppBar( title: String, navigateBack: () -> Unit, + snackbarHost: @Composable (() -> Unit) = {}, floatingActionButton: @Composable (() -> Unit) = {}, content: @Composable (PaddingValues) -> Unit ) { @@ -40,6 +42,7 @@ fun ScaffoldWithTopAppBar( navigateBack = navigateBack ) }, + snackbarHost = snackbarHost, floatingActionButton = floatingActionButton, contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal), content = content @@ -84,6 +87,7 @@ fun ScaffoldWithClassicTopAppBar( title: String, navigateBack: () -> Unit, floatingActionButton: @Composable (() -> Unit) = {}, + actions: @Composable (RowScope.() -> Unit) = {}, contentWindowInsets: WindowInsets = WindowInsets.systemBars, content: @Composable (PaddingValues) -> Unit ) { @@ -99,6 +103,7 @@ fun ScaffoldWithClassicTopAppBar( ClassicTopAppBar( title = title, scrollBehavior = topAppBarScrollBehavior, + actions = actions, navigateBack = navigateBack ) }, diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt new file mode 100644 index 0000000..cafbf0f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScoreIndicator.kt @@ -0,0 +1,54 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.ui.theme.TokushoTheme +import org.xtimms.tokusho.utils.lang.toStringPositiveValueOrUnknown + +@Composable +fun SmallScoreIndicator( + score: Float?, + modifier: Modifier = Modifier, + fontSize: TextUnit = 16.sp, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.StarOutline, + contentDescription = stringResource(R.string.mean_score), + tint = MaterialTheme.colorScheme.outline + ) + if (score != null) { + Text( + text = (score.times(5.0F)).toStringPositiveValueOrUnknown(), + modifier = Modifier.padding(horizontal = 4.dp), + color = MaterialTheme.colorScheme.outline, + fontSize = fontSize + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun SmallScoreIndicatorPreview() { + TokushoTheme { + SmallScoreIndicator(score = 1f) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt index 088a74a..0d8e2f4 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -14,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.outlined.RssFeed import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings @@ -28,6 +30,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable @@ -42,11 +46,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState +import kotlinx.collections.immutable.persistentListOf import org.xtimms.tokusho.R import org.xtimms.tokusho.core.initialOffset import org.xtimms.tokusho.core.motion.materialSharedAxisXIn import org.xtimms.tokusho.core.motion.materialSharedAxisXOut import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION +import org.xtimms.tokusho.sections.feed.FEED_DESTINATION import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION @@ -124,7 +130,7 @@ fun TopAppBar( modifier = modifier.padding(end = 16.dp), ) { IconButton( - onClick = { }, + onClick = { navController.navigate(FEED_DESTINATION) }, modifier = Modifier.padding(0.dp), ) { Icon( @@ -218,6 +224,7 @@ fun SmallTopAppBar( fun ClassicTopAppBar( title: String, scrollBehavior: TopAppBarScrollBehavior? = null, + actions: @Composable (RowScope.() -> Unit), navigateBack: () -> Unit, ) { androidx.compose.material3.TopAppBar( @@ -225,6 +232,7 @@ fun ClassicTopAppBar( navigationIcon = { BackIconButton(onClick = navigateBack) }, + actions = actions, scrollBehavior = scrollBehavior ) } @@ -248,7 +256,16 @@ fun DefaultTopAppBarWithChipsPreview() { TokushoTheme { SmallTopAppBarWithChips( title = "Tokusho", - chips = listOf("Chip 1", "Chip 2", "Chip 3", "Chip 4", "Chip 1", "Chip 2", "Chip 3", "Chip 4"), + chips = listOf( + "Chip 1", + "Chip 2", + "Chip 3", + "Chip 4", + "Chip 1", + "Chip 2", + "Chip 3", + "Chip 4" + ), navigateBack = {} ) } @@ -273,7 +290,15 @@ fun ClassicTopAppBarPreview() { TokushoTheme { ClassicTopAppBar( title = "Tokusho", - navigateBack = {} + navigateBack = {}, + actions = { + IconButton(onClick = { /*TODO*/ }) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Localized description" + ) + } + } ) } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt b/app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt new file mode 100644 index 0000000..82804b9 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/effects/Snowflake.kt @@ -0,0 +1,60 @@ +package org.xtimms.tokusho.core.components.effects + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import kotlin.random.Random + +data class Snowflake( + var x: Float, + var y: Float, + var radius: Float, + var speed: Float +) + +@Composable +fun SnowfallEffect() { + val snowflakes = remember { List(100) { generateRandomSnowflake() } } + val infiniteTransition = rememberInfiniteTransition(label = "") + + val offsetY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 100f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 10000, easing = LinearEasing) + ), label = "" + ) + + Canvas(modifier = Modifier.fillMaxSize().background(Color.Transparent)) { + snowflakes.forEach { snowflake -> + drawSnowflake(snowflake, offsetY % size.height) + } + } +} + +fun generateRandomSnowflake(): Snowflake { + return Snowflake( + x = Random.nextFloat(), + y = Random.nextFloat() * 1000f, + radius = Random.nextFloat() * 2f + 2f, // Snowflake size + speed = Random.nextFloat() * 0.5f + 1f // Falling speed + ) +} + +fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) { + val newY = (snowflake.y + offsetY * snowflake.speed) % size.height + drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY)) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt b/app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt new file mode 100644 index 0000000..65db733 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/icons/ArrowDecisionOutline.kt @@ -0,0 +1,53 @@ +package org.xtimms.tokusho.core.components.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +public val Icons.Outlined.ArrowDecisionOutline: ImageVector + get() { + if (_arrow_decision_outline != null) { + return _arrow_decision_outline!! + } + _arrow_decision_outline = materialIcon(name = "Outlined.ArrowDecisionOutline") { + materialPath { + moveTo(9.64f, 13.4f) + curveTo(8.63f, 12.5f, 7.34f, 12.03f, 6.0f, 12.0f) + verticalLineTo(15.0f) + lineTo(2.0f, 11.0f) + lineTo(6.0f, 7.0f) + verticalLineTo(10.0f) + curveTo(7.67f, 10.0f, 9.3f, 10.57f, 10.63f, 11.59f) + curveTo(10.22f, 12.15f, 9.89f, 12.76f, 9.64f, 13.4f) + moveTo(18.0f, 15.0f) + verticalLineTo(12.0f) + curveTo(17.5f, 12.0f, 13.5f, 12.16f, 13.05f, 16.2f) + curveTo(14.61f, 16.75f, 15.43f, 18.47f, 14.88f, 20.03f) + curveTo(14.33f, 21.59f, 12.61f, 22.41f, 11.05f, 21.86f) + curveTo(9.5f, 21.3f, 8.67f, 19.59f, 9.22f, 18.03f) + curveTo(9.5f, 17.17f, 10.2f, 16.5f, 11.05f, 16.2f) + curveTo(11.34f, 12.61f, 14.4f, 9.88f, 18.0f, 10.0f) + verticalLineTo(7.0f) + lineTo(22.0f, 11.0f) + lineTo(18.0f, 15.0f) + moveTo(13.0f, 19.0f) + arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 18.0f) + arcTo(1.0f, 1.0f, 0.0f, false, false, 11.0f, 19.0f) + arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 20.0f) + arcTo(1.0f, 1.0f, 0.0f, false, false, 13.0f, 19.0f) + moveTo(11.0f, 11.12f) + curveTo(11.58f, 10.46f, 12.25f, 9.89f, 13.0f, 9.43f) + verticalLineTo(5.0f) + horizontalLineTo(16.0f) + lineTo(12.0f, 1.0f) + lineTo(8.0f, 5.0f) + horizontalLineTo(11.0f) + verticalLineTo(11.12f) + close() + } + } + return _arrow_decision_outline!! + } + +private var _arrow_decision_outline: ImageVector? = null \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt new file mode 100644 index 0000000..037cfc2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Kotatsu.kt @@ -0,0 +1,293 @@ +package org.xtimms.tokusho.core.components.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +public val Icons.Filled.Kotatsu: ImageVector + get() { + if (_kotatsu != null) { + return _kotatsu!! + } + _kotatsu = Builder(name = "Kotatsu", defaultWidth = 1406.2.dp, defaultHeight = 1406.2.dp, + viewportWidth = 1406.2f, viewportHeight = 1406.2f).apply { + path(fill = SolidColor(Color(0xFF0058C9)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(391.7f, 270.7f) + curveToRelative(-51.6f, 18.6f, -96.2f, 88.4f, -117.9f, 183.6f) + curveToRelative(-7.8f, 34.8f, -15.1f, 93.5f, -15.1f, 121.7f) + verticalLineToRelative(19.7f) + lineToRelative(-23.3f, 36.6f) + curveToRelative(-65.0f, 101.3f, -124.6f, 206.8f, -180.5f, 319.2f) + curveTo(5.1f, 1051.0f, 0.0f, 1063.2f, 0.0f, 1080.9f) + curveToRelative(0.0f, 7.8f, 2.0f, 18.0f, 4.0f, 22.2f) + curveToRelative(6.6f, 12.0f, 22.2f, 24.4f, 39.7f, 31.0f) + lineToRelative(16.0f, 6.2f) + lineToRelative(651.1f, -0.2f) + curveToRelative(633.1f, 0.0f, 651.1f, -0.4f, 661.2f, -5.3f) + curveToRelative(32.6f, -16.9f, 43.0f, -51.7f, 26.2f, -88.0f) + curveToRelative(-63.8f, -139.0f, -150.5f, -296.8f, -229.6f, -418.5f) + lineToRelative(-19.1f, -29.5f) + lineToRelative(-2.2f, -33.7f) + curveToRelative(-8.7f, -129.4f, -36.1f, -208.6f, -92.0f, -266.5f) + curveToRelative(-24.2f, -24.8f, -33.5f, -30.4f, -50.6f, -30.6f) + curveToRelative(-23.9f, 0.0f, -39.9f, 10.9f, -75.6f, 52.1f) + curveToRelative(-35.2f, 40.4f, -42.4f, 50.1f, -66.9f, 86.4f) + curveToRelative(-12.0f, 17.7f, -27.0f, 38.3f, -33.2f, 45.5f) + lineToRelative(-11.3f, 13.5f) + horizontalLineToRelative(-117.0f) + lineToRelative(-117.0f, -0.2f) + lineTo(560.2f, 429.0f) + curveTo(515.0f, 359.2f, 440.7f, 274.5f, 419.6f, 268.7f) + curveTo(406.8f, 264.9f, 409.0f, 264.5f, 391.7f, 270.7f) + close() + moveTo(466.2f, 666.4f) + curveToRelative(8.9f, 6.2f, 11.3f, 11.8f, 14.4f, 37.7f) + curveToRelative(4.0f, 30.6f, 7.7f, 34.8f, 27.5f, 32.4f) + curveToRelative(18.0f, -2.2f, 32.6f, 3.6f, 40.8f, 16.6f) + curveToRelative(16.0f, 25.9f, -11.5f, 80.2f, -50.6f, 99.3f) + curveToRelative(-14.0f, 7.1f, -19.1f, 7.8f, -42.8f, 7.8f) + curveToRelative(-22.8f, 0.0f, -28.8f, -1.1f, -39.4f, -6.7f) + curveToRelative(-31.2f, -16.4f, -50.3f, -40.3f, -58.3f, -71.8f) + curveToRelative(-4.0f, -16.6f, -4.2f, -21.7f, -1.1f, -36.3f) + curveToRelative(4.2f, -21.1f, 11.5f, -35.2f, 24.8f, -50.1f) + curveTo(404.8f, 669.5f, 449.1f, 654.4f, 466.2f, 666.4f) + close() + moveTo(964.0f, 669.0f) + curveToRelative(8.7f, 7.3f, 9.3f, 9.7f, 13.5f, 43.4f) + curveToRelative(2.6f, 20.6f, 8.7f, 26.8f, 25.3f, 24.2f) + curveToRelative(16.0f, -2.2f, 29.9f, 2.2f, 39.2f, 12.9f) + curveToRelative(15.1f, 18.2f, 6.2f, 53.4f, -20.8f, 82.2f) + curveToRelative(-21.7f, 23.1f, -35.2f, 28.4f, -69.2f, 28.8f) + curveToRelative(-25.9f, 0.0f, -29.1f, -0.5f, -42.8f, -8.2f) + curveToRelative(-20.2f, -11.3f, -38.4f, -29.9f, -47.7f, -49.0f) + curveToRelative(-6.2f, -12.8f, -8.2f, -20.8f, -8.9f, -39.2f) + curveToRelative(-0.9f, -21.1f, 0.0f, -25.0f, 7.7f, -41.0f) + curveToRelative(14.0f, -30.4f, 35.5f, -49.2f, 65.0f, -57.0f) + curveTo(945.2f, 660.2f, 954.7f, 661.1f, 964.0f, 669.0f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(1806.5f, 939.2f) + verticalLineTo(464.0f) + horizontalLineToRelative(88.3f) + verticalLineToRelative(475.3f) + lineTo(1806.5f, 939.2f) + lineTo(1806.5f, 939.2f) + close() + moveTo(1885.3f, 827.2f) + lineToRelative(-4.8f, -104.5f) + lineTo(2129.7f, 464.0f) + horizontalLineToRelative(99.1f) + lineToRelative(-207.1f, 220.0f) + lineToRelative(-48.9f, 53.6f) + lineTo(1885.3f, 827.2f) + close() + moveTo(2137.8f, 939.2f) + lineToRelative(-181.9f, -216.6f) + lineToRelative(58.4f, -64.5f) + lineTo(2241.0f, 939.2f) + horizontalLineTo(2137.8f) + lineTo(2137.8f, 939.2f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(2440.6f, 944.0f) + curveToRelative(-37.1f, 0.0f, -70.2f, -8.0f, -99.1f, -24.1f) + curveToRelative(-29.0f, -16.1f, -51.8f, -38.1f, -68.6f, -66.2f) + curveToRelative(-16.8f, -28.1f, -25.1f, -60.0f, -25.1f, -95.7f) + curveToRelative(0.0f, -36.2f, 8.4f, -68.2f, 25.1f, -96.1f) + curveToRelative(16.7f, -27.8f, 39.6f, -49.7f, 68.6f, -65.5f) + reflectiveCurveToRelative(62.0f, -23.8f, 99.1f, -23.8f) + curveToRelative(37.6f, 0.0f, 70.9f, 7.9f, 100.1f, 23.8f) + curveToRelative(29.2f, 15.9f, 52.1f, 37.6f, 68.6f, 65.2f) + curveToRelative(16.5f, 27.6f, 24.8f, 59.8f, 24.8f, 96.4f) + curveToRelative(0.0f, 35.8f, -8.3f, 67.7f, -24.8f, 95.7f) + curveToRelative(-16.5f, 28.1f, -39.4f, 50.1f, -68.6f, 66.2f) + curveTo(2511.6f, 936.0f, 2478.2f, 944.0f, 2440.6f, 944.0f) + close() + moveTo(2440.6f, 871.3f) + curveToRelative(20.8f, 0.0f, 39.4f, -4.5f, 55.7f, -13.6f) + curveToRelative(16.3f, -9.0f, 29.1f, -22.2f, 38.4f, -39.4f) + curveToRelative(9.3f, -17.2f, 13.9f, -37.3f, 13.9f, -60.4f) + curveToRelative(0.0f, -23.5f, -4.6f, -43.8f, -13.9f, -60.8f) + curveToRelative(-9.3f, -17.0f, -22.1f, -30.0f, -38.4f, -39.0f) + curveToRelative(-16.3f, -9.0f, -34.6f, -13.6f, -55.0f, -13.6f) + curveToRelative(-20.8f, 0.0f, -39.3f, 4.5f, -55.3f, 13.6f) + curveToRelative(-16.1f, 9.0f, -28.8f, 22.1f, -38.3f, 39.0f) + curveToRelative(-9.5f, 17.0f, -14.3f, 37.2f, -14.3f, 60.8f) + curveToRelative(0.0f, 23.1f, 4.8f, 43.2f, 14.3f, 60.4f) + reflectiveCurveToRelative(22.3f, 30.3f, 38.3f, 39.4f) + curveTo(2402.0f, 866.8f, 2420.3f, 871.3f, 2440.6f, 871.3f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(2667.4f, 647.3f) + verticalLineToRelative(-67.9f) + horizontalLineToRelative(241.7f) + verticalLineToRelative(67.9f) + horizontalLineTo(2667.4f) + close() + moveTo(2852.1f, 944.0f) + curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f) + curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f) + verticalLineTo(496.6f) + horizontalLineToRelative(84.9f) + verticalLineToRelative(323.2f) + curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f) + curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f) + curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f) + lineToRelative(23.8f, 60.4f) + curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f) + curveTo(2882.4f, 941.9f, 2867.4f, 944.0f, 2852.1f, 944.0f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(3112.8f, 944.0f) + curveToRelative(-27.2f, 0.0f, -50.9f, -4.6f, -71.3f, -13.9f) + curveToRelative(-20.4f, -9.3f, -36.1f, -22.2f, -47.2f, -38.7f) + curveToRelative(-11.1f, -16.5f, -16.6f, -35.2f, -16.6f, -56.0f) + curveToRelative(0.0f, -20.4f, 4.9f, -38.7f, 14.6f, -55.0f) + curveToRelative(9.7f, -16.3f, 25.7f, -29.2f, 47.9f, -38.7f) + reflectiveCurveToRelative(51.6f, -14.3f, 88.3f, -14.3f) + horizontalLineToRelative(105.2f) + verticalLineToRelative(56.3f) + horizontalLineToRelative(-99.1f) + curveToRelative(-29.0f, 0.0f, -48.4f, 4.7f, -58.4f, 13.9f) + curveToRelative(-10.0f, 9.3f, -14.9f, 20.7f, -14.9f, 34.3f) + curveToRelative(0.0f, 15.4f, 6.1f, 27.6f, 18.3f, 36.7f) + curveToRelative(12.2f, 9.1f, 29.2f, 13.6f, 50.9f, 13.6f) + curveToRelative(20.8f, 0.0f, 39.5f, -4.8f, 56.0f, -14.3f) + reflectiveCurveToRelative(28.4f, -23.5f, 35.6f, -42.1f) + lineToRelative(14.3f, 50.9f) + curveToRelative(-8.1f, 21.3f, -22.7f, 37.8f, -43.8f, 49.6f) + reflectiveCurveTo(3144.9f, 944.0f, 3112.8f, 944.0f) + close() + moveTo(3226.8f, 939.2f) + verticalLineToRelative(-73.3f) + lineToRelative(-4.8f, -15.6f) + verticalLineTo(722.0f) + curveToRelative(0.0f, -24.9f, -7.5f, -44.2f, -22.4f, -58.0f) + reflectiveCurveToRelative(-37.6f, -20.7f, -67.9f, -20.7f) + curveToRelative(-20.4f, 0.0f, -40.4f, 3.2f, -60.1f, 9.5f) + curveToRelative(-19.7f, 6.3f, -36.3f, 15.2f, -49.9f, 26.5f) + lineToRelative(-33.3f, -61.8f) + curveToRelative(19.4f, -14.9f, 42.6f, -26.1f, 69.6f, -33.6f) + curveToRelative(26.9f, -7.5f, 54.9f, -11.2f, 83.8f, -11.2f) + curveToRelative(52.5f, 0.0f, 93.1f, 12.6f, 121.9f, 37.7f) + curveToRelative(28.7f, 25.1f, 43.1f, 63.9f, 43.1f, 116.4f) + verticalLineToRelative(212.5f) + lineTo(3226.8f, 939.2f) + lineTo(3226.8f, 939.2f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(3367.4f, 647.3f) + verticalLineToRelative(-67.9f) + horizontalLineToRelative(241.7f) + verticalLineToRelative(67.9f) + horizontalLineTo(3367.4f) + close() + moveTo(3552.0f, 944.0f) + curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f) + curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f) + verticalLineTo(496.6f) + horizontalLineToRelative(84.9f) + verticalLineToRelative(323.2f) + curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f) + curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f) + curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f) + lineToRelative(23.8f, 60.4f) + curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f) + curveTo(3582.3f, 941.9f, 3567.4f, 944.0f, 3552.0f, 944.0f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(3815.5f, 944.0f) + curveToRelative(-30.3f, 0.0f, -59.4f, -4.0f, -87.2f, -11.9f) + reflectiveCurveToRelative(-49.9f, -17.5f, -66.2f, -28.9f) + lineToRelative(32.6f, -64.5f) + curveToRelative(16.3f, 10.4f, 35.8f, 19.0f, 58.4f, 25.8f) + curveToRelative(22.6f, 6.8f, 45.3f, 10.2f, 67.9f, 10.2f) + curveToRelative(26.7f, 0.0f, 46.0f, -3.6f, 58.0f, -10.9f) + curveToRelative(12.0f, -7.2f, 18.0f, -17.0f, 18.0f, -29.2f) + curveToRelative(0.0f, -10.0f, -4.1f, -17.5f, -12.2f, -22.8f) + curveToRelative(-8.1f, -5.2f, -18.8f, -9.2f, -31.9f, -11.9f) + curveToRelative(-13.1f, -2.7f, -27.7f, -5.2f, -43.8f, -7.5f) + reflectiveCurveToRelative(-32.1f, -5.3f, -48.2f, -9.2f) + curveToRelative(-16.1f, -3.8f, -30.7f, -9.5f, -43.8f, -17.0f) + reflectiveCurveToRelative(-23.8f, -17.5f, -31.9f, -30.2f) + curveToRelative(-8.1f, -12.7f, -12.2f, -29.4f, -12.2f, -50.2f) + curveToRelative(0.0f, -23.1f, 6.6f, -43.1f, 19.7f, -60.1f) + curveToRelative(13.1f, -17.0f, 31.6f, -30.1f, 55.3f, -39.4f) + curveToRelative(23.8f, -9.3f, 51.9f, -13.9f, 84.5f, -13.9f) + curveToRelative(24.4f, 0.0f, 49.1f, 2.7f, 74.0f, 8.1f) + curveToRelative(24.9f, 5.4f, 45.5f, 13.1f, 61.8f, 23.1f) + lineToRelative(-32.6f, 64.5f) + curveToRelative(-17.2f, -10.4f, -34.5f, -17.5f, -51.9f, -21.4f) + curveToRelative(-17.4f, -3.8f, -34.7f, -5.8f, -51.9f, -5.8f) + curveToRelative(-25.8f, 0.0f, -44.9f, 3.9f, -57.4f, 11.5f) + curveToRelative(-12.4f, 7.7f, -18.7f, 17.4f, -18.7f, 29.2f) + curveToRelative(0.0f, 10.9f, 4.1f, 19.0f, 12.2f, 24.4f) + curveToRelative(8.1f, 5.4f, 18.8f, 9.7f, 31.9f, 12.9f) + curveToRelative(13.1f, 3.2f, 27.7f, 5.8f, 43.8f, 7.8f) + reflectiveCurveToRelative(32.0f, 5.1f, 47.9f, 9.2f) + curveToRelative(15.8f, 4.1f, 30.4f, 9.6f, 43.8f, 16.6f) + curveToRelative(13.3f, 7.0f, 24.1f, 16.9f, 32.3f, 29.5f) + curveToRelative(8.1f, 12.7f, 12.2f, 29.2f, 12.2f, 49.6f) + curveToRelative(0.0f, 22.6f, -6.7f, 42.3f, -20.0f, 59.1f) + curveToRelative(-13.4f, 16.8f, -32.3f, 29.8f, -56.7f, 39.0f) + curveTo(3878.6f, 939.3f, 3849.4f, 944.0f, 3815.5f, 944.0f) + close() + } + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(4206.5f, 944.0f) + curveToRelative(-30.8f, 0.0f, -57.9f, -5.8f, -81.5f, -17.3f) + curveToRelative(-23.5f, -11.5f, -41.9f, -29.2f, -55.0f, -53.0f) + reflectiveCurveToRelative(-19.7f, -53.7f, -19.7f, -90.0f) + verticalLineToRelative(-207.0f) + horizontalLineToRelative(84.9f) + verticalLineToRelative(195.5f) + curveToRelative(0.0f, 32.6f, 7.4f, 56.9f, 22.1f, 73.0f) + reflectiveCurveToRelative(35.6f, 24.1f, 62.8f, 24.1f) + curveToRelative(19.9f, 0.0f, 37.2f, -4.1f, 51.9f, -12.2f) + curveToRelative(14.7f, -8.1f, 26.2f, -20.4f, 34.6f, -36.7f) + reflectiveCurveToRelative(12.6f, -36.4f, 12.6f, -60.4f) + verticalLineTo(576.7f) + horizontalLineToRelative(84.9f) + verticalLineToRelative(362.5f) + horizontalLineToRelative(-80.8f) + verticalLineToRelative(-97.8f) + lineToRelative(14.3f, 29.9f) + curveToRelative(-12.2f, 23.5f, -30.1f, 41.5f, -53.6f, 54.0f) + curveTo(4260.4f, 937.8f, 4234.6f, 944.0f, 4206.5f, 944.0f) + close() + } + } + .build() + return _kotatsu!! + } + +private var _kotatsu: ImageVector? = null diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt b/app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt new file mode 100644 index 0000000..f69a59b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/shape/WavyShape.kt @@ -0,0 +1,46 @@ +package org.xtimms.tokusho.core.components.shape + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.ceil + +class WavyShape( + private val period: Dp, + private val amplitude: Dp, + private val shift: Float, +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ) = Outline.Generic(Path().apply { + val halfPeriod = with(density) { period.toPx() } / 2 + val amplitude = with(density) { amplitude.toPx() } + + val wavyPath = Path().apply { + moveTo(x = 0f, y = 0f) + lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift) + repeat(ceil(size.height / halfPeriod + 3).toInt()) { i -> + relativeQuadraticBezierTo( + dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1), + dy1 = halfPeriod / 2, + dx2 = 0f, + dy2 = halfPeriod, + ) + } + lineTo(0f, size.height) + } + val boundsPath = Path().apply { + addRect(Rect(offset = Offset.Zero, size = size)) + } + op(wavyPath, boundsPath, PathOperation.Intersect) + }) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt b/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt index 290d964..110d819 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/DatabasePrePopulateCallback.kt @@ -21,5 +21,84 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba 0L, ) ) + db.execSQL( + "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)", + arrayOf( + System.currentTimeMillis(), + 1, + resources.getString(R.string.reading), + SortOrder.NEWEST.name, + 1, + 1, + 0L, + ) + ) + db.execSQL( + "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)", + arrayOf( + System.currentTimeMillis(), + 1, + resources.getString(R.string.completed), + SortOrder.NEWEST.name, + 1, + 1, + 0L, + ) + ) + db.execSQL( + "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)", + arrayOf( + System.currentTimeMillis(), + 1, + resources.getString(R.string.dropped), + SortOrder.NEWEST.name, + 1, + 1, + 0L, + ) + ) + db.execSQL( + "INSERT INTO sources (source, enabled, sort_key) VALUES (?,?,?)", + arrayOf( + "MANGADEX", + 1, + 1, + ) + ) + db.execSQL( + "INSERT INTO sources (source, enabled, sort_key) VALUES (?,?,?)", + arrayOf( + "DESUME", + 1, + 1, + ) + ) + db.execSQL( + "INSERT INTO manga (manga_id, title, alt_title, url, public_url, rating, nsfw, cover_url, large_cover_url, state, author, source) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + arrayOf( + 4427365311541330000, + "Seitokai ni mo Ana wa Aru!", + "", + "822c9883-385c-4fd0-9523-16e7789cbeae", + "https://mangadex.org/title/822c9883-385c-4fd0-9523-16e7789cbeae", + -1.0, + 0, + "https://uploads.mangadex.org/covers/822c9883-385c-4fd0-9523-16e7789cbeae/542f379f-adee-4d27-bdd1-ffd81b140851.jpg.256.jpg", + "https://uploads.mangadex.org/covers/822c9883-385c-4fd0-9523-16e7789cbeae/542f379f-adee-4d27-bdd1-ffd81b140851.jpg", + "FINISHED", + "Muchi Maro", + "MANGADEX", + ) + ) + db.execSQL( + "INSERT INTO favourites (manga_id, category_id, sort_key, created_at, deleted_at) VALUES (?,?,?,?,?)", + arrayOf( + 4427365311541330000, + 1, + 0, + 1705944302882, + 0, + ) + ) } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt index ec0b656..b811731 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt @@ -10,11 +10,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.xtimms.tokusho.core.database.dao.BookmarksDao import org.xtimms.tokusho.core.database.dao.FavouriteCategoriesDao import org.xtimms.tokusho.core.database.dao.FavouritesDao import org.xtimms.tokusho.core.database.dao.HistoryDao import org.xtimms.tokusho.core.database.dao.MangaDao import org.xtimms.tokusho.core.database.dao.MangaSourcesDao +import org.xtimms.tokusho.core.database.dao.TagsDao +import org.xtimms.tokusho.core.database.entity.BookmarkEntity import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity import org.xtimms.tokusho.core.database.entity.FavouriteEntity import org.xtimms.tokusho.core.database.entity.HistoryEntity @@ -34,12 +37,15 @@ const val DATABASE_VERSION = 1 MangaSourceEntity::class, HistoryEntity::class, FavouriteEntity::class, - FavouriteCategoryEntity::class + FavouriteCategoryEntity::class, + BookmarkEntity::class ], version = DATABASE_VERSION ) abstract class TokushoDatabase : RoomDatabase() { + abstract fun getTagsDao(): TagsDao + abstract fun getHistoryDao(): HistoryDao abstract fun getMangaDao(): MangaDao @@ -50,10 +56,12 @@ abstract class TokushoDatabase : RoomDatabase() { abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao + abstract fun getBookmarksDao(): BookmarksDao + } fun TokushoDatabase(context: Context): TokushoDatabase = Room - .databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db.db") + .databaseBuilder(context, TokushoDatabase::class.java, "tokusho-db") .addCallback(DatabasePrePopulateCallback(context.resources)) .build() diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt new file mode 100644 index 0000000..aa88c69 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/BookmarksDao.kt @@ -0,0 +1,57 @@ +package org.xtimms.tokusho.core.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow +import org.xtimms.tokusho.core.database.entity.BookmarkEntity +import org.xtimms.tokusho.core.database.entity.MangaWithTags + +@Dao +abstract class BookmarksDao { + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") + abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity? + + @Query("SELECT * FROM bookmarks WHERE page_id = :pageId") + abstract suspend fun find(pageId: Long): BookmarkEntity? + + @Transaction + @Query( + "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", + ) + abstract suspend fun findAll(): Map> + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent") + abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow + + @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent") + abstract fun observe(mangaId: Long): Flow> + + @Transaction + @Query( + "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent", + ) + abstract fun observe(): Flow>> + + @Insert + abstract suspend fun insert(entity: BookmarkEntity) + + @Delete + abstract suspend fun delete(entity: BookmarkEntity) + + @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId") + abstract suspend fun delete(mangaId: Long, pageId: Long): Int + + @Query("DELETE FROM bookmarks WHERE page_id = :pageId") + abstract suspend fun delete(pageId: Long): Int + + @Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page") + abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int + + @Upsert + abstract suspend fun upsert(bookmarks: Collection) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt index 04ffd75..c8846dc 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/FavouritesDao.kt @@ -86,6 +86,9 @@ abstract class FavouritesDao { @Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0") abstract fun observeMangaCount(): Flow + @Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0 AND category_id = :categoryId") + abstract fun observeMangaCountInCategory(categoryId: Long): Flow + @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)") abstract suspend fun findAllManga(): List diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt index 2e9e705..3edf3dd 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt @@ -2,8 +2,15 @@ package org.xtimms.tokusho.core.database.dao import androidx.room.Dao import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Transaction +import androidx.room.Upsert +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow +import org.intellij.lang.annotations.Language import org.xtimms.tokusho.core.database.entity.MangaSourceEntity +import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder @Dao abstract class MangaSourcesDao { @@ -26,4 +33,49 @@ abstract class MangaSourcesDao { @Query("UPDATE sources SET enabled = 0") abstract suspend fun disableAllSources() + @Upsert + abstract suspend fun upsert(entry: MangaSourceEntity) + + fun observeEnabled(order: SourcesSortOrder): Flow> { + val orderBy = getOrderBy(order) + + @Language("RoomSql") + val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy") + return observeImpl(query) + } + + suspend fun findAllEnabled(order: SourcesSortOrder): List { + val orderBy = getOrderBy(order) + + @Language("RoomSql") + val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy") + return findAllImpl(query) + } + + @Transaction + open suspend fun setEnabled(source: String, isEnabled: Boolean) { + if (updateIsEnabled(source, isEnabled) == 0) { + val entity = MangaSourceEntity( + source = source, + isEnabled = isEnabled, + sortKey = getMaxSortKey() + 1, + ) + upsert(entity) + } + } + + @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source") + protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int + + @RawQuery(observedEntities = [MangaSourceEntity::class]) + protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow> + + @RawQuery + protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List + + private fun getOrderBy(order: SourcesSortOrder) = when (order) { + SourcesSortOrder.ALPHABETIC -> "source ASC" + SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC" + SourcesSortOrder.MANUAL -> "sort_key ASC" + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt new file mode 100644 index 0000000..9d16a6a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/TagsDao.kt @@ -0,0 +1,88 @@ +package org.xtimms.tokusho.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import org.xtimms.tokusho.core.database.entity.TagEntity + +@Dao +abstract class TagsDao { + + @Query("SELECT * FROM tags WHERE source = :source") + abstract suspend fun findTags(source: String): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites) + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""", + ) + abstract suspend fun findPopularTags(limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE tags.source = :source + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""", + ) + abstract suspend fun findPopularTags(source: String, limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE tags.source = :source + GROUP BY tags.title + ORDER BY COUNT(manga_id) ASC + LIMIT :limit""", + ) + abstract suspend fun findRareTags(source: String, limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE tags.source = :source AND title LIKE :query + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""", + ) + abstract suspend fun findTags(source: String, query: String, limit: Int): List + + @Query( + """SELECT tags.* FROM tags + LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id + WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites) + GROUP BY tags.title + ORDER BY COUNT(manga_id) DESC + LIMIT :limit""", + ) + abstract suspend fun findTags(query: String, limit: Int): List + + @Query( + """ + SELECT tags.* FROM manga_tags + LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id + WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId) + GROUP BY tags.tag_id + ORDER BY COUNT(manga_id) DESC; + """, + ) + abstract suspend fun findRelatedTags(tagId: Long): List + + @Query( + """ + SELECT tags.* FROM manga_tags + LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id + WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids)) + GROUP BY tags.tag_id + ORDER BY COUNT(manga_id) DESC; + """, + ) + abstract suspend fun findRelatedTags(ids: Set): List + + @Upsert + abstract suspend fun upsert(tags: Iterable) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt new file mode 100644 index 0000000..73e06b0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/BookmarksEntity.kt @@ -0,0 +1,28 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "bookmarks", + primaryKeys = ["manga_id", "page_id"], + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE + ), + ] +) +data class BookmarkEntity( + @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, + @ColumnInfo(name = "page_id", index = true) val pageId: Long, + @ColumnInfo(name = "chapter_id") val chapterId: Long, + @ColumnInfo(name = "page") val page: Int, + @ColumnInfo(name = "scroll") val scroll: Int, + @ColumnInfo(name = "image") val imageUrl: String, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "percent") val percent: Float, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt index b9e72a3..8add3a4 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/EntityMapping.kt @@ -6,6 +6,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.xtimms.tokusho.core.model.Bookmark import org.xtimms.tokusho.core.model.FavouriteCategory import org.xtimms.tokusho.core.model.ListSortOrder import org.xtimms.tokusho.core.model.MangaHistory @@ -58,6 +59,24 @@ fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags()) fun Collection.toMangaList() = map { it.toManga() } +fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( + manga = manga, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = Instant.ofEpochMilli(createdAt), + percent = percent, +) + +fun Collection.toBookmarks(manga: Manga) = map { + it.toBookmark(manga) +} + +@JvmName("bookmarksIds") +fun Collection.ids() = map { it.pageId } + // Model to entity fun Manga.toEntity() = MangaEntity( @@ -84,6 +103,17 @@ fun MangaTag.toEntity() = TagEntity( fun Collection.toEntities() = map(MangaTag::toEntity) +fun Bookmark.toEntity() = BookmarkEntity( + mangaId = manga.id, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = createdAt.toEpochMilli(), + percent = percent, +) + // Other fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { diff --git a/app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt b/app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt new file mode 100644 index 0000000..079aa1d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/exceptions/CloudflareProtectedException.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.core.exceptions + +import okhttp3.Headers +import okio.IOException +import org.koitharu.kotatsu.parsers.model.MangaSource + +class CloudflareProtectedException( + val url: String, + val source: MangaSource?, + @Transient val headers: Headers, +) : IOException("Protected by Cloudflare") \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt b/app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt new file mode 100644 index 0000000..ce21490 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/Bookmark.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.core.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.xtimms.tokusho.utils.hasImageExtension +import java.time.Instant + +data class Bookmark( + val manga: Manga, + val pageId: Long, + val chapterId: Long, + val page: Int, + val scroll: Int, + val imageUrl: String, + val createdAt: Instant, + val percent: Float, +) : ListModel { + + val directImageUrl: String? + get() = if (isImageUrlDirect()) imageUrl else null + + val imageLoadData: Any + get() = if (isImageUrlDirect()) imageUrl else toMangaPage() + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is Bookmark && + manga.id == other.manga.id && + chapterId == other.chapterId && + page == other.page + } + + fun toMangaPage() = MangaPage( + id = pageId, + url = imageUrl, + preview = null, + source = manga.source, + ) + + private fun isImageUrlDirect(): Boolean { + return hasImageExtension(imageUrl) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt new file mode 100644 index 0000000..41b97b4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/LocalManga.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.core.model + +import androidx.core.net.toFile +import androidx.core.net.toUri +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.xtimms.tokusho.utils.system.creationTime +import java.io.File + +data class LocalManga( + val manga: Manga, + val file: File = manga.url.toUri().toFile(), +) { + + var createdAt: Long = -1L + private set + get() { + if (field == -1L) { + field = file.creationTime + } + return field + } + + fun isMatchesQuery(query: String): Boolean { + return manga.title.contains(query, ignoreCase = true) || + manga.altTitle?.contains(query, ignoreCase = true) == true + } + + fun containsTags(tags: Set): Boolean { + return manga.tags.containsAll(tags) + } + + fun containsAnyTag(tags: Set): Boolean { + return tags.any { tag -> + manga.tags.contains(tag) + } + } + + override fun toString(): String { + return "LocalManga(${file.path}: ${manga.title})" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt index 7c1333d..012658c 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/model/Manga.kt @@ -1,8 +1,64 @@ package org.xtimms.tokusho.core.model +import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.utils.system.iterator +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols fun Collection.distinctById() = distinctBy { it.id } -fun Collection.findById(id: Long) = find { x -> x.id == id } \ No newline at end of file +fun Collection.findById(id: Long) = find { x -> x.id == id } + +fun Manga.getPreferredBranch(history: MangaHistory?): String? { + val ch = chapters + if (ch.isNullOrEmpty()) { + return null + } + if (history != null) { + val currentChapter = ch.findById(history.chapterId) + if (currentChapter != null) { + return currentChapter.branch + } + } + val groups = ch.groupBy { it.branch } + if (groups.size == 1) { + return groups.keys.first() + } + for (locale in LocaleListCompat.getAdjustedDefault()) { + val displayLanguage = locale.getDisplayLanguage(locale) + val displayName = locale.getDisplayName(locale) + val candidates = HashMap>(3) + for (branch in groups.keys) { + if (branch != null && ( + branch.contains(displayLanguage, ignoreCase = true) || + branch.contains(displayName, ignoreCase = true) + ) + ) { + candidates[branch] = groups[branch] ?: continue + } + } + if (candidates.isNotEmpty()) { + return candidates.maxBy { it.value.size }.key + } + } + return groups.maxByOrNull { it.value.size }?.key +} + +private val chaptersNumberFormat = DecimalFormat("#.#").also { f -> + f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also { + it.decimalSeparator = '.' + } +} + +fun MangaChapter.formatNumber(): String? { + if (number <= 0f) { + return null + } + return chaptersNumberFormat.format(number.toDouble()) +} + +val Manga.isLocal: Boolean + get() = source == MangaSource.LOCAL \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt index feac6a1..ad722af 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt @@ -1,5 +1,6 @@ package org.xtimms.tokusho.core.model +import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource fun MangaSource(name: String): MangaSource { @@ -7,4 +8,6 @@ fun MangaSource(name: String): MangaSource { if (it.name == name) return it } return MangaSource.DUMMY -} \ No newline at end of file +} + +fun MangaSource.isNsfw() = contentType == ContentType.HENTAI \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt index 96bdc6a..31af83f 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt @@ -15,9 +15,11 @@ import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar import org.xtimms.tokusho.core.network.cookies.MutableCookieJar import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar import org.xtimms.tokusho.core.network.interceptors.CacheLimitInterceptor +import org.xtimms.tokusho.core.network.interceptors.CloudflareInterceptor import org.xtimms.tokusho.core.network.interceptors.CommonHeadersInterceptor import org.xtimms.tokusho.core.network.interceptors.GZipInterceptor import org.xtimms.tokusho.core.network.interceptors.RateLimitInterceptor +import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.data.LocalStorageManager import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -58,8 +60,12 @@ interface NetworkModule { readTimeout(60, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS) cookieJar(cookieJar) + if (AppSettings.isSSLBypassEnabled()) { + bypassSSLErrors() + } cache(cache) addInterceptor(GZipInterceptor()) + addInterceptor(CloudflareInterceptor()) addInterceptor(RateLimitInterceptor()) }.build() diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt b/app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt new file mode 100644 index 0000000..5cd8861 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/SSLBypass.kt @@ -0,0 +1,29 @@ +package org.xtimms.tokusho.core.network + +import android.annotation.SuppressLint +import okhttp3.OkHttpClient +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager + +@SuppressLint("CustomX509TrustManager") +fun OkHttpClient.Builder.bypassSSLErrors() = also { builder -> + runCatching { + val trustAllCerts = object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) = Unit + + override fun checkServerTrusted(chain: Array, authType: String) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() + } + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, arrayOf(trustAllCerts), SecureRandom()) + val sslSocketFactory: SSLSocketFactory = sslContext.socketFactory + builder.sslSocketFactory(sslSocketFactory, trustAllCerts) + builder.hostnameVerifier { _, _ -> true } + }.onFailure { + it.printStackTrace() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt new file mode 100644 index 0000000..6845f84 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CloudflareInterceptor.kt @@ -0,0 +1,32 @@ +package org.xtimms.tokusho.core.network.interceptors + +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.internal.closeQuietly +import org.jsoup.Jsoup +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.core.exceptions.CloudflareProtectedException +import java.net.HttpURLConnection.HTTP_FORBIDDEN +import java.net.HttpURLConnection.HTTP_UNAVAILABLE + +class CloudflareInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) { + val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use { + Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString()) + } ?: return response + if (content.getElementById("challenge-error-title") != null) { + val request = response.request + response.closeQuietly() + throw CloudflareProtectedException( + url = request.url.toString(), + source = request.tag(MangaSource::class.java), + headers = request.headers, + ) + } + } + return response + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt index 705e66b..9bf02ca 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/CommonHeadersInterceptor.kt @@ -13,6 +13,7 @@ import org.xtimms.tokusho.BuildConfig import org.xtimms.tokusho.core.network.CommonHeaders import org.xtimms.tokusho.core.parser.MangaRepository import org.xtimms.tokusho.core.parser.RemoteMangaRepository +import java.net.IDN import javax.inject.Inject import javax.inject.Singleton @@ -39,6 +40,10 @@ class CommonHeadersInterceptor @Inject constructor( if (headersBuilder[CommonHeaders.USER_AGENT] == null) { headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE } + if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) { + val idn = IDN.toASCII(repository.domain) + headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/") + } val newRequest = request.newBuilder().headers(headersBuilder.build()).build() return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest) } @@ -46,7 +51,7 @@ class CommonHeadersInterceptor @Inject constructor( private fun Headers.Builder.trySet(name: String, value: String) = try { set(name, value) } catch (e: IllegalArgumentException) { - + e.printStackTrace() } private class ProxyChain( diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt index 9681079..ba476a5 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaDataRepository.kt @@ -1,8 +1,11 @@ package org.xtimms.tokusho.core.parser +import androidx.room.withTransaction import dagger.Reusable import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.entity.toEntities +import org.xtimms.tokusho.core.database.entity.toEntity import org.xtimms.tokusho.core.database.entity.toManga import javax.inject.Inject import javax.inject.Provider @@ -28,4 +31,12 @@ class MangaDataRepository @Inject constructor( else -> null } + suspend fun storeManga(manga: Manga) { + db.withTransaction { + val tags = manga.tags.toEntities() + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(manga.toEntity(), tags) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt index 0357f0c..de92f61 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt @@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.xtimms.tokusho.core.cache.ContentCache +import org.xtimms.tokusho.core.parser.local.LocalMangaRepository import java.lang.ref.WeakReference import java.util.EnumMap import java.util.Locale @@ -50,6 +51,7 @@ interface MangaRepository { @Singleton class Factory @Inject constructor( + private val localMangaRepository: LocalMangaRepository, private val loaderContext: MangaLoaderContext, private val contentCache: ContentCache, ) { @@ -58,6 +60,9 @@ interface MangaRepository { @AnyThread fun create(source: MangaSource): MangaRepository { + if (source == MangaSource.LOCAL) { + return localMangaRepository + } cache[source]?.get()?.let { return it } return synchronized(cache) { cache[source]?.get()?.let { return it } diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt index 746e5a8..5822d3c 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt @@ -24,10 +24,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.xtimms.tokusho.BuildConfig import org.xtimms.tokusho.core.cache.ContentCache import org.xtimms.tokusho.core.cache.SafeDeferred +import org.xtimms.tokusho.core.prefs.SourceSettings import org.xtimms.tokusho.utils.lang.processLifecycleScope import java.util.Locale @@ -58,6 +60,12 @@ class RemoteMangaRepository( override val isTagsExclusionSupported: Boolean get() = parser.isTagsExclusionSupported + var domain: String + get() = parser.domain + set(value) { + getConfig()[parser.configKeyDomain] = value + } + val domains: Array get() = parser.configKeyDomain.presetValues @@ -119,6 +127,8 @@ class RemoteMangaRepository( return details.await() } + private fun getConfig() = parser.config as SourceSettings + @OptIn(ExperimentalStdlibApi::class) private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred { var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt new file mode 100644 index 0000000..8915092 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/CbzFilter.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.core.parser.local + +import android.net.Uri +import org.xtimms.tokusho.utils.system.URI_SCHEME_ZIP +import java.io.File + +private fun isCbzExtension(ext: String?): Boolean { + return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true) +} + +fun hasCbzExtension(string: String): Boolean { + val ext = string.substringAfterLast('.', "") + return isCbzExtension(ext) +} + +fun File.hasCbzExtension() = isCbzExtension(extension) + +fun Uri.isZipUri() = scheme.let { + it == URI_SCHEME_ZIP || it == "cbz" || it == "zip" +} + +fun Uri.isFileUri() = scheme == "file" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt new file mode 100644 index 0000000..68f0362 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/DownloadFormat.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.core.parser.local + +enum class DownloadFormat { + + AUTOMATIC, + SINGLE_CBZ, + MULTIPLE_CBZ, +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt new file mode 100644 index 0000000..7ac8d1f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/LocalMangaRepository.kt @@ -0,0 +1,220 @@ +package org.xtimms.tokusho.core.parser.local + +import android.net.Uri +import androidx.core.net.toFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.core.model.LocalManga +import org.xtimms.tokusho.core.model.isLocal +import org.xtimms.tokusho.core.parser.MangaRepository +import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput +import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput +import org.xtimms.tokusho.core.parser.local.output.LocalMangaUtil +import org.xtimms.tokusho.data.LocalStorageManager +import org.xtimms.tokusho.utils.AlphanumComparator +import org.xtimms.tokusho.utils.CompositeMutex2 +import org.xtimms.tokusho.utils.system.children +import org.xtimms.tokusho.utils.system.deleteAwait +import org.xtimms.tokusho.utils.system.filterWith +import java.io.File +import java.util.EnumSet +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +private const val MAX_PARALLELISM = 4 + +@Singleton +class LocalMangaRepository @Inject constructor( + private val storageManager: LocalStorageManager, + @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, +) : MangaRepository { + + override val source = MangaSource.LOCAL + private val locks = CompositeMutex2() + + override val isMultipleTagsSupported: Boolean = true + override val isTagsExclusionSupported: Boolean = true + override val isSearchSupported: Boolean = true + override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) + override val states = emptySet() + override val contentRatings = emptySet() + + override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + if (offset > 0) { + return emptyList() + } + val list = getRawList() + when (filter) { + is MangaListFilter.Search -> { + list.retainAll { x -> x.isMatchesQuery(filter.query) } + } + + is MangaListFilter.Advanced -> { + if (filter.tags.isNotEmpty()) { + list.retainAll { x -> x.containsTags(filter.tags) } + } + if (filter.tagsExclude.isNotEmpty()) { + list.removeAll { x -> x.containsAnyTag(filter.tags) } + } + when (filter.sortOrder) { + SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) + SortOrder.RATING -> list.sortByDescending { it.manga.rating } + SortOrder.NEWEST, + SortOrder.UPDATED, + -> list.sortByDescending { it.createdAt } + + else -> Unit + } + } + + null -> Unit + } + return list.unwrap() + } + + override suspend fun getDetails(manga: Manga): Manga = when { + manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) { + "Manga is not local or saved" + } + + else -> LocalMangaInput.of(manga).getManga().manga + } + + override suspend fun getPages(chapter: MangaChapter): List { + return LocalMangaInput.of(chapter).getPages(chapter) + } + + suspend fun delete(manga: Manga): Boolean { + val file = Uri.parse(manga.url).toFile() + val result = file.deleteAwait() + if (result) { + localStorageChanges.emit(null) + } + return result + } + + suspend fun deleteChapters(manga: Manga, ids: Set) { + lockManga(manga.id) + try { + val subject = if (manga.isLocal) manga else checkNotNull(findSavedManga(manga)) { + "Manga is not stored on local storage" + }.manga + LocalMangaUtil(subject).deleteChapters(ids) + localStorageChanges.emit(LocalManga(subject)) + } finally { + unlockManga(manga.id) + } + } + + suspend fun getRemoteManga(localManga: Manga): Manga? { + return runCatchingCancellable { + LocalMangaInput.of(localManga).getMangaInfo()?.takeUnless { it.isLocal } + }.onFailure { + it.printStackTrace() + }.getOrNull() + } + + suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable { + // fast path + LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let { + return it.getManga() + } + // slow path + val files = getAllFiles() + return channelFlow { + for (file in files) { + launch { + val mangaInput = LocalMangaInput.of(file) + runCatchingCancellable { + val mangaInfo = mangaInput.getMangaInfo() + if (mangaInfo != null && mangaInfo.id == remoteManga.id) { + send(mangaInput) + } + }.onFailure { + it.printStackTrace() + } + } + } + }.firstOrNull()?.getManga() + }.onFailure { + it.printStackTrace() + }.getOrNull() + + override suspend fun getPageUrl(page: MangaPage) = page.url + + override suspend fun getTags() = emptySet() + + override suspend fun getLocales() = emptySet() + + override suspend fun getRelated(seed: Manga): List = emptyList() + + suspend fun getOutputDir(manga: Manga): File? { + val defaultDir = storageManager.getDefaultWriteableDir() + if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) { + return defaultDir + } + return storageManager.getWriteableDirs() + .firstOrNull { + LocalMangaOutput.get(it, manga) != null + } ?: defaultDir + } + + suspend fun cleanup(): Boolean { + if (locks.isNotEmpty()) { + return false + } + val dirs = storageManager.getWriteableDirs() + runInterruptible(Dispatchers.IO) { + dirs.flatMap { dir -> + dir.children().filterWith(TempFileFilter()) + }.forEach { file -> + file.deleteRecursively() + } + } + return true + } + + suspend fun lockManga(id: Long) { + locks.lock(id) + } + + fun unlockManga(id: Long) { + locks.unlock(id) + } + + private suspend fun getRawList(): ArrayList { + val files = getAllFiles().toList() // TODO remove toList() + return coroutineScope { + val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) + files.map { file -> + async(dispatcher) { + runCatchingCancellable { LocalMangaInput.ofOrNull(file)?.getManga() }.getOrNull() + } + }.awaitAll() + }.filterNotNullTo(ArrayList(files.size)) + } + + private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir -> + dir.children() + } + + private fun Collection.unwrap(): List = map { it.manga } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt new file mode 100644 index 0000000..c214e5c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/MangaIndex.kt @@ -0,0 +1,200 @@ +package org.xtimms.tokusho.core.parser.local + +import androidx.annotation.WorkerThread +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.find +import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault +import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault +import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault +import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.xtimms.tokusho.BuildConfig +import org.xtimms.tokusho.core.model.isLocal +import org.xtimms.tokusho.utils.AlphanumComparator +import java.io.File + +class MangaIndex(source: String?) { + + private val json: JSONObject = source?.let(::JSONObject) ?: JSONObject() + + fun setMangaInfo(manga: Manga) { + require(!manga.isLocal) { "Local manga information cannot be stored" } + json.put("id", manga.id) + json.put("title", manga.title) + json.put("title_alt", manga.altTitle) + json.put("url", manga.url) + json.put("public_url", manga.publicUrl) + json.put("author", manga.author) + json.put("cover", manga.coverUrl) + json.put("description", manga.description) + json.put("rating", manga.rating) + json.put("nsfw", manga.isNsfw) + json.put("state", manga.state?.name) + json.put("source", manga.source.name) + json.put("cover_large", manga.largeCoverUrl) + json.put( + "tags", + JSONArray().also { a -> + for (tag in manga.tags) { + val jo = JSONObject() + jo.put("key", tag.key) + jo.put("title", tag.title) + a.put(jo) + } + }, + ) + if (!json.has("chapters")) { + json.put("chapters", JSONObject()) + } + json.put("app_id", BuildConfig.APPLICATION_ID) + json.put("app_version", BuildConfig.VERSION_CODE) + } + + fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching { + val source = MangaSource.valueOf(json.getString("source")) + Manga( + id = json.getLong("id"), + title = json.getString("title"), + altTitle = json.getStringOrNull("title_alt"), + url = json.getString("url"), + publicUrl = json.getStringOrNull("public_url").orEmpty(), + author = json.getStringOrNull("author"), + largeCoverUrl = json.getStringOrNull("cover_large"), + source = source, + rating = json.getDouble("rating").toFloat(), + isNsfw = json.getBooleanOrDefault("nsfw", false), + coverUrl = json.getString("cover"), + state = json.getStringOrNull("state")?.let { stateString -> + MangaState.entries.find(stateString) + }, + description = json.getStringOrNull("description"), + tags = json.getJSONArray("tags").mapJSONToSet { x -> + MangaTag( + title = x.getString("title").toTitleCase(), + key = x.getString("key"), + source = source, + ) + }, + chapters = getChapters(json.getJSONObject("chapters"), source), + ) + }.getOrNull() + + fun getCoverEntry(): String? = json.getStringOrNull("cover_entry") + + fun addChapter(chapter: IndexedValue, filename: String?) { + val chapters = json.getJSONObject("chapters") + if (!chapters.has(chapter.value.id.toString())) { + val jo = JSONObject() + jo.put("number", chapter.value.number) + jo.put("volume", chapter.value.volume) + jo.put("url", chapter.value.url) + jo.put("name", chapter.value.name) + jo.put("uploadDate", chapter.value.uploadDate) + jo.put("scanlator", chapter.value.scanlator) + jo.put("branch", chapter.value.branch) + jo.put("entries", "%08d_%03d\\d{3}".format(chapter.value.branch.hashCode(), chapter.index + 1)) + jo.put("file", filename) + chapters.put(chapter.value.id.toString(), jo) + } + } + + fun removeChapter(id: Long): Boolean { + return json.getJSONObject("chapters").remove(id.toString()) != null + } + + fun getChapterFileName(chapterId: Long): String? { + return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file") + } + + fun setCoverEntry(name: String) { + json.put("cover_entry", name) + } + + fun getChapterNamesPattern(chapter: MangaChapter) = Regex( + json.getJSONObject("chapters") + .getJSONObject(chapter.id.toString()) + .getString("entries"), + ) + + fun sortChaptersByName() { + val jo = json.getJSONObject("chapters") + val list = ArrayList(jo.length()) + jo.keys().forEach { id -> + val item = jo.getJSONObject(id) + item.put("id", id) + list.add(item) + } + val comparator = AlphanumComparator() + list.sortWith(compareBy(comparator) { it.getString("name") }) + val newJo = JSONObject() + list.forEachIndexed { i, obj -> + obj.put("number", i + 1) + val id = obj.remove("id") as String + newJo.put(id, obj) + } + json.put("chapters", newJo) + } + + fun clear() { + val keys = json.keys() + while (keys.hasNext()) { + json.remove(keys.next()) + } + } + + fun setFrom(other: MangaIndex) { + clear() + other.json.keys().forEach { key -> + json.putOpt(key, other.json.opt(key)) + } + } + + private fun getChapters(json: JSONObject, source: MangaSource): List { + val chapters = ArrayList(json.length()) + for (k in json.keys()) { + val v = json.getJSONObject(k) + chapters.add( + MangaChapter( + id = k.toLong(), + name = v.getString("name"), + url = v.getString("url"), + number = v.getFloatOrDefault("number", 0f), + volume = v.getIntOrDefault("volume", 0), + uploadDate = v.getLongOrDefault("uploadDate", 0L), + scanlator = v.getStringOrNull("scanlator"), + branch = v.getStringOrNull("branch"), + source = source, + ), + ) + } + return chapters.sortedBy { it.number } + } + + override fun toString(): String = if (BuildConfig.DEBUG) { + json.toString(4) + } else { + json.toString() + } + + companion object { + + @WorkerThread + fun read(file: File): MangaIndex? { + if (file.exists() && file.canRead()) { + val text = file.readText() + if (text.length > 2) { + return MangaIndex(text) + } + } + return null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt new file mode 100644 index 0000000..4d01cca --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/Qualifiers.kt @@ -0,0 +1,7 @@ +package org.xtimms.tokusho.core.parser.local + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LocalStorageChanges \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt new file mode 100644 index 0000000..9346e5c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/TempFileFilter.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.core.parser.local + +import java.io.File +import java.io.FileFilter + +class TempFileFilter : FileFilter { + + override fun accept(file: File): Boolean { + return file.name.endsWith(".tmp", ignoreCase = true) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt new file mode 100644 index 0000000..eaa08cf --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaDirInput.kt @@ -0,0 +1,158 @@ +package org.xtimms.tokusho.core.parser.local.input + +import androidx.core.net.toFile +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.toCamelCase +import org.xtimms.tokusho.core.model.LocalManga +import org.xtimms.tokusho.core.parser.local.MangaIndex +import org.xtimms.tokusho.core.parser.local.hasCbzExtension +import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput +import org.xtimms.tokusho.utils.AlphanumComparator +import org.xtimms.tokusho.utils.hasImageExtension +import org.xtimms.tokusho.utils.lang.longHashCode +import org.xtimms.tokusho.utils.lang.toListSorted +import org.xtimms.tokusho.utils.system.children +import org.xtimms.tokusho.utils.system.creationTime +import org.xtimms.tokusho.utils.system.walkCompat +import java.io.File +import java.util.TreeMap +import java.util.zip.ZipFile + +/** + * Manga {Folder} + * |--- index.json (optional) + * |--- Chapter 1.cbz + * |--- Page 1.png + * : + * L--- Page x.png + * |--- Chapter 2.cbz + * : + * L--- Chapter x.cbz + */ +class LocalMangaDirInput(root: File) : LocalMangaInput(root) { + + override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) { + val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX)) + val mangaUri = root.toUri().toString() + val chapterFiles = getChaptersFiles() + val info = index?.getMangaInfo() + val cover = fileUri( + root, + index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(), + ) + val manga = info?.copy2( + source = MangaSource.LOCAL, + url = mangaUri, + coverUrl = cover, + largeCoverUrl = cover, + chapters = info.chapters?.mapIndexedNotNull { i, c -> + val fileName = index.getChapterFileName(c.id) + val file = if (fileName != null) { + chapterFiles[fileName] + } else { + // old downloads + chapterFiles.values.elementAtOrNull(i) + } ?: return@mapIndexedNotNull null + c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL) + }, + ) ?: Manga( + id = root.absolutePath.longHashCode(), + title = root.name.toHumanReadable(), + url = mangaUri, + publicUrl = mangaUri, + source = MangaSource.LOCAL, + coverUrl = findFirstImageEntry().orEmpty(), + chapters = chapterFiles.values.mapIndexed { i, f -> + MangaChapter( + id = "$i${f.name}".longHashCode(), + name = f.nameWithoutExtension.toHumanReadable(), + number = 0f, + volume = 0, + source = MangaSource.LOCAL, + uploadDate = f.creationTime, + url = f.toUri().toString(), + scanlator = null, + branch = null, + ) + }, + altTitle = null, + rating = -1f, + isNsfw = false, + tags = setOf(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + ) + LocalManga(manga, root) + } + + override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { + val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX)) + index?.getMangaInfo() + } + + override suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { + val file = chapter.url.toUri().toFile() + if (file.isDirectory) { + file.children() + .filter { it.isFile && hasImageExtension(it) } + .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) + .map { + val pageUri = it.toUri().toString() + MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL) + } + } else { + ZipFile(file).use { zip -> + zip.entries() + .asSequence() + .filter { x -> !x.isDirectory } + .map { it.name } + .toListSorted(AlphanumComparator()) + .map { + val pageUri = zipUri(file, it) + MangaPage( + id = pageUri.longHashCode(), + url = pageUri, + preview = null, + source = MangaSource.LOCAL, + ) + } + } + } + } + + private fun String.toHumanReadable() = replace("_", " ").toCamelCase() + + private fun getChaptersFiles() = root.walkCompat(includeDirectories = true) + .filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() } + .associateByTo(TreeMap(AlphanumComparator())) { it.name } + + private fun findFirstImageEntry(): String? { + return root.walkCompat(includeDirectories = false) + .firstOrNull { hasImageExtension(it) }?.toUri()?.toString() + ?: run { + val cbz = root.walkCompat(includeDirectories = false) + .firstOrNull { it.hasCbzExtension() } ?: return null + ZipFile(cbz).use { zip -> + zip.entries().asSequence() + .firstOrNull { !it.isDirectory && hasImageExtension(it.name) } + ?.let { zipUri(cbz, it.name) } + } + } + } + + private fun fileUri(base: File, name: String): String { + return File(base, name).toUri().toString() + } + + private fun File.isChapterDirectory(): Boolean { + return isDirectory && children().any { hasImageExtension(it) } + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt new file mode 100644 index 0000000..c4e2160 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaInput.kt @@ -0,0 +1,111 @@ +package org.xtimms.tokusho.core.parser.local.input + +import android.net.Uri +import androidx.core.net.toFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import org.xtimms.tokusho.core.model.LocalManga +import org.xtimms.tokusho.core.parser.local.hasCbzExtension +import java.io.File + +sealed class LocalMangaInput( + protected val root: File, +) { + + abstract suspend fun getManga(): LocalManga + + abstract suspend fun getMangaInfo(): Manga? + + abstract suspend fun getPages(chapter: MangaChapter): List + + companion object { + + fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile()) + + fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile()) + + fun of(file: File): LocalMangaInput = when { + file.isDirectory -> LocalMangaDirInput(file) + else -> LocalMangaZipInput(file) + } + + fun ofOrNull(file: File): LocalMangaInput? = when { + file.isDirectory -> LocalMangaDirInput(file) + hasCbzExtension(file.name) -> LocalMangaZipInput(file) + else -> null + } + + suspend fun find(roots: Iterable, manga: Manga): LocalMangaInput? = channelFlow { + val fileName = manga.title.toFileNameSafe() + for (root in roots) { + launch { + val dir = File(root, fileName) + val zip = File(root, "$fileName.cbz") + val input = when { + dir.isDirectory -> LocalMangaDirInput(dir) + zip.isFile -> LocalMangaZipInput(zip) + else -> null + } + val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull() + if (info?.id == manga.id) { + send(input) + } + } + } + }.flowOn(Dispatchers.Default).firstOrNull() + + @JvmStatic + protected fun zipUri(file: File, entryName: String): String = + Uri.fromParts("cbz", file.path, entryName).toString() + + @JvmStatic + protected fun Manga.copy2( + url: String, + coverUrl: String, + largeCoverUrl: String, + chapters: List?, + source: MangaSource, + ) = Manga( + id = id, + title = title, + altTitle = altTitle, + url = url, + publicUrl = publicUrl, + rating = rating, + isNsfw = isNsfw, + coverUrl = coverUrl, + tags = tags, + state = state, + author = author, + largeCoverUrl = largeCoverUrl, + description = description, + chapters = chapters, + source = source, + ) + + @JvmStatic + protected fun MangaChapter.copy( + url: String, + source: MangaSource, + ) = MangaChapter( + id = id, + name = name, + number = number, + volume = volume, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = source, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt new file mode 100644 index 0000000..b5600d9 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/input/LocalMangaZipInput.kt @@ -0,0 +1,154 @@ +package org.xtimms.tokusho.core.parser.local.input + +import android.net.Uri +import android.webkit.MimeTypeMap +import androidx.collection.ArraySet +import androidx.core.net.toFile +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.toCamelCase +import org.xtimms.tokusho.core.model.LocalManga +import org.xtimms.tokusho.core.parser.local.MangaIndex +import org.xtimms.tokusho.core.parser.local.output.LocalMangaOutput +import org.xtimms.tokusho.utils.AlphanumComparator +import org.xtimms.tokusho.utils.lang.longHashCode +import org.xtimms.tokusho.utils.lang.toListSorted +import org.xtimms.tokusho.utils.system.readText +import java.io.File +import java.util.Enumeration +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +/** + * Manga archive {.cbz or .zip file} + * |--- index.json (optional) + * |--- Page 1.png + * |--- Page 2.png + * : + * L--- Page x.png + */ +class LocalMangaZipInput(root: File) : LocalMangaInput(root) { + + override suspend fun getManga(): LocalManga { + val manga = runInterruptible(Dispatchers.IO) { + ZipFile(root).use { zip -> + val fileUri = root.toUri().toString() + val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) + val index = entry?.let(zip::readText)?.let(::MangaIndex) + val info = index?.getMangaInfo() + if (info != null) { + val cover = zipUri( + root, + entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), + ) + return@use info.copy2( + source = MangaSource.LOCAL, + url = fileUri, + coverUrl = cover, + largeCoverUrl = cover, + chapters = info.chapters?.map { c -> + c.copy(url = fileUri, source = MangaSource.LOCAL) + }, + ) + } + // fallback + val title = root.nameWithoutExtension.replace("_", " ").toCamelCase() + val chapters = ArraySet() + for (x in zip.entries()) { + if (!x.isDirectory) { + chapters += x.name.substringBeforeLast(File.separatorChar, "") + } + } + val uriBuilder = root.toUri().buildUpon() + Manga( + id = root.absolutePath.longHashCode(), + title = title, + url = fileUri, + publicUrl = fileUri, + source = MangaSource.LOCAL, + coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), + chapters = chapters.sortedWith(AlphanumComparator()) + .mapIndexed { i, s -> + MangaChapter( + id = "$i$s".longHashCode(), + name = s.ifEmpty { title }, + number = 0f, + volume = 0, + source = MangaSource.LOCAL, + uploadDate = 0L, + url = uriBuilder.fragment(s).build().toString(), + scanlator = null, + branch = null, + ) + }, + altTitle = null, + rating = -1f, + isNsfw = false, + tags = setOf(), + state = null, + author = null, + largeCoverUrl = null, + description = null, + ) + } + } + return LocalManga(manga, root) + } + + override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { + ZipFile(root).use { zip -> + val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX) + val index = entry?.let(zip::readText)?.let(::MangaIndex) + index?.getMangaInfo() + } + } + + override suspend fun getPages(chapter: MangaChapter): List { + return runInterruptible(Dispatchers.IO) { + val uri = Uri.parse(chapter.url) + val file = uri.toFile() + val zip = ZipFile(file) + val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex) + var entries = zip.entries().asSequence() + entries = if (index != null) { + val pattern = index.getChapterNamesPattern(chapter) + entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) } + } else { + val parent = uri.fragment.orEmpty() + entries.filter { x -> + !x.isDirectory && x.name.substringBeforeLast( + File.separatorChar, + "", + ) == parent + } + } + entries + .toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) + .map { x -> + val entryUri = zipUri(file, x.name) + MangaPage( + id = entryUri.longHashCode(), + url = entryUri, + preview = null, + source = MangaSource.LOCAL, + ) + } + } + } + + private fun findFirstImageEntry(entries: Enumeration): ZipEntry? { + val list = entries.toList() + .filterNot { it.isDirectory } + .sortedWith(compareBy(AlphanumComparator()) { x -> x.name }) + val map = MimeTypeMap.getSingleton() + return list.firstOrNull { + map.getMimeTypeFromExtension(it.name.substringAfterLast('.')) + ?.startsWith("image/") == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt new file mode 100644 index 0000000..4b8c52b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaDirOutput.kt @@ -0,0 +1,136 @@ +package org.xtimms.tokusho.core.parser.local.output + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import org.xtimms.tokusho.core.model.isLocal +import org.xtimms.tokusho.core.parser.local.MangaIndex +import org.xtimms.tokusho.core.zip.ZipOutput +import org.xtimms.tokusho.utils.system.deleteAwait +import org.xtimms.tokusho.utils.system.takeIfReadable +import java.io.File + +class LocalMangaDirOutput( + rootFile: File, + manga: Manga, +) : LocalMangaOutput(rootFile) { + + private val chaptersOutput = HashMap() + private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) + private val mutex = Mutex() + + init { + if (!manga.isLocal) { + index.setMangaInfo(manga) + } + } + + override suspend fun mergeWithExisting() = Unit + + override suspend fun addCover(file: File, ext: String) = mutex.withLock { + val name = buildString { + append("cover") + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + runInterruptible(Dispatchers.IO) { + file.copyTo(File(rootFile, name), overwrite = true) + } + index.setCoverEntry(name) + flushIndex() + } + + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = mutex.withLock { + val output = chaptersOutput.getOrPut(chapter.value) { + ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) + } + val name = buildString { + append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } + index.addChapter(chapter, chapterFileName(chapter)) + } + + override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock { + val output = chaptersOutput.remove(chapter) ?: return@withLock false + output.flushAndFinish() + flushIndex() + true + } + + override suspend fun finish() = mutex.withLock { + flushIndex() + for (output in chaptersOutput.values) { + output.flushAndFinish() + } + chaptersOutput.clear() + } + + override suspend fun cleanup() = mutex.withLock { + for (output in chaptersOutput.values) { + output.file.deleteAwait() + } + } + + override fun close() { + for (output in chaptersOutput.values) { + output.close() + } + } + + suspend fun deleteChapter(chapterId: Long) = mutex.withLock { + val chapter = checkNotNull(index.getMangaInfo()?.chapters?.withIndex()) { + "No chapters found" + }.find { x -> x.value.id == chapterId } ?: error("Chapter not found") + val chapterDir = File(rootFile, chapterFileName(chapter)) + chapterDir.deleteAwait() + index.removeChapter(chapterId) + } + + fun setIndex(newIndex: MangaIndex) { + index.setFrom(newIndex) + } + + private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) { + finish() + close() + val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP)) + file.renameTo(resFile) + } + + private fun chapterFileName(chapter: IndexedValue): String { + index.getChapterFileName(chapter.value.id)?.let { + return it + } + val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(18) + var i = 0 + while (true) { + val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz" + if (!File(rootFile, name).exists()) { + return name + } + i++ + } + } + + private suspend fun flushIndex() = runInterruptible(Dispatchers.IO) { + File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString()) + } + + companion object { + + private const val FILENAME_PATTERN = "%08d_%03d%03d" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt new file mode 100644 index 0000000..ae95592 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaOutput.kt @@ -0,0 +1,109 @@ +package org.xtimms.tokusho.core.parser.local.output + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.Closeable +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.toFileNameSafe +import org.xtimms.tokusho.core.parser.local.DownloadFormat +import org.xtimms.tokusho.core.parser.local.input.LocalMangaInput +import java.io.File + +sealed class LocalMangaOutput( + val rootFile: File, +) : Closeable { + + abstract suspend fun mergeWithExisting() + + abstract suspend fun addCover(file: File, ext: String) + + abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) + + abstract suspend fun flushChapter(chapter: MangaChapter): Boolean + + abstract suspend fun finish() + + abstract suspend fun cleanup() + + companion object { + + const val ENTRY_NAME_INDEX = "index.json" + const val SUFFIX_TMP = ".tmp" + private val mutex = Mutex() + + suspend fun getOrCreate( + root: File, + manga: Manga, + format: DownloadFormat, + ): LocalMangaOutput = withContext(Dispatchers.IO) { + val targetFormat = if (format == DownloadFormat.AUTOMATIC) { + if (manga.chapters.let { it != null && it.size <= 3 }) { + DownloadFormat.SINGLE_CBZ + } else { + DownloadFormat.MULTIPLE_CBZ + } + } else { + format + } + checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat)) + } + + suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) { + getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC) + } + + private suspend fun getImpl( + root: File, + manga: Manga, + onlyIfExists: Boolean, + format: DownloadFormat, + ): LocalMangaOutput? { + mutex.withLock { + var i = 0 + val baseName = manga.title.toFileNameSafe() + while (true) { + val fileName = if (i == 0) baseName else baseName + "_$i" + val dir = File(root, fileName) + val zip = File(root, "$fileName.cbz") + i++ + return when { + dir.isDirectory -> { + if (canWriteTo(dir, manga)) { + LocalMangaDirOutput(dir, manga) + } else { + continue + } + } + + zip.isFile -> if (canWriteTo(zip, manga)) { + LocalMangaZipOutput(zip, manga) + } else { + continue + } + + !onlyIfExists -> when (format) { + DownloadFormat.AUTOMATIC -> null + DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga) + DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga) + } + + else -> null + } + } + } + } + + private suspend fun canWriteTo(file: File, manga: Manga): Boolean { + val info = runCatchingCancellable { + LocalMangaInput.of(file).getMangaInfo() + }.onFailure { + it.printStackTrace() + }.getOrNull() ?: return false + return info.id == manga.id + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt new file mode 100644 index 0000000..e768f93 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaUtil.kt @@ -0,0 +1,45 @@ +package org.xtimms.tokusho.core.parser.local.output + +import androidx.core.net.toFile +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource + +class LocalMangaUtil( + private val manga: Manga, +) { + + init { + require(manga.source == MangaSource.LOCAL) { + "Expected LOCAL source but ${manga.source} found" + } + } + + suspend fun deleteChapters(ids: Set) { + newOutput().use { output -> + when (output) { + is LocalMangaZipOutput -> runInterruptible(Dispatchers.IO) { + LocalMangaZipOutput.filterChapters(output, ids) + } + + is LocalMangaDirOutput -> { + for (id in ids) { + output.deleteChapter(id) + } + output.finish() + } + } + } + } + + private suspend fun newOutput(): LocalMangaOutput = runInterruptible(Dispatchers.IO) { + val file = manga.url.toUri().toFile() + if (file.isDirectory) { + LocalMangaDirOutput(file, manga) + } else { + LocalMangaZipOutput(file, manga) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt new file mode 100644 index 0000000..3b6baa7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/local/output/LocalMangaZipOutput.kt @@ -0,0 +1,156 @@ +package org.xtimms.tokusho.core.parser.local.output + +import androidx.annotation.WorkerThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.xtimms.tokusho.core.model.isLocal +import org.xtimms.tokusho.core.parser.local.MangaIndex +import org.xtimms.tokusho.core.zip.ZipOutput +import org.xtimms.tokusho.utils.system.deleteAwait +import org.xtimms.tokusho.utils.system.readText +import java.io.File +import java.util.zip.ZipFile + +class LocalMangaZipOutput( + rootFile: File, + manga: Manga, +) : LocalMangaOutput(rootFile) { + + private val output = ZipOutput(File(rootFile.path + ".tmp")) + private val index = MangaIndex(null) + private val mutex = Mutex() + + init { + if (!manga.isLocal) { + index.setMangaInfo(manga) + } + } + + override suspend fun mergeWithExisting() = mutex.withLock { + if (rootFile.exists()) { + runInterruptible(Dispatchers.IO) { + mergeWith(rootFile) + } + } + } + + override suspend fun addCover(file: File, ext: String) = mutex.withLock { + val name = buildString { + append(FILENAME_PATTERN.format(0, 0, 0)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } + index.setCoverEntry(name) + } + + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = mutex.withLock { + val name = buildString { + append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) + if (ext.isNotEmpty() && ext.length <= 4) { + append('.') + append(ext) + } + } + runInterruptible(Dispatchers.IO) { + output.put(name, file) + } + index.addChapter(chapter, null) + } + + override suspend fun flushChapter(chapter: MangaChapter): Boolean = false + + override suspend fun finish() = mutex.withLock { + runInterruptible(Dispatchers.IO) { + output.put(ENTRY_NAME_INDEX, index.toString()) + output.finish() + output.close() + } + rootFile.deleteAwait() + output.file.renameTo(rootFile) + Unit + } + + override suspend fun cleanup() = mutex.withLock { + output.file.deleteAwait() + Unit + } + + override fun close() { + output.close() + } + + @WorkerThread + private fun mergeWith(other: File) { + var otherIndex: MangaIndex? = null + ZipFile(other).use { zip -> + for (entry in zip.entries()) { + if (entry.name == ENTRY_NAME_INDEX) { + otherIndex = MangaIndex( + zip.getInputStream(entry).use { + it.reader().readText() + }, + ) + } else { + output.copyEntryFrom(zip, entry) + } + } + } + otherIndex?.getMangaInfo()?.chapters?.withIndex()?.let { chapters -> + for (chapter in chapters) { + index.addChapter(chapter, null) + } + } + } + + companion object { + + private const val FILENAME_PATTERN = "%08d_%03d%03d" + + @WorkerThread + fun filterChapters(subject: LocalMangaZipOutput, idsToRemove: Set) { + ZipFile(subject.rootFile).use { zip -> + val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX))) + idsToRemove.forEach { id -> index.removeChapter(id) } + val patterns = requireNotNull(index.getMangaInfo()?.chapters).map { + index.getChapterNamesPattern(it) + } + val coverEntryName = index.getCoverEntry() + for (entry in zip.entries()) { + when { + entry.name == ENTRY_NAME_INDEX -> { + subject.output.put(ENTRY_NAME_INDEX, index.toString()) + } + + entry.isDirectory -> { + subject.output.addDirectory(entry.name) + } + + entry.name == coverEntryName -> { + subject.output.copyEntryFrom(zip, entry) + } + + else -> { + val name = entry.name.substringBefore('.') + if (patterns.any { it.matches(name) }) { + subject.output.copyEntryFrom(zip, entry) + } + } + } + } + subject.output.finish() + subject.output.close() + subject.rootFile.delete() + subject.output.file.renameTo(subject.rootFile) + } + } + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt index b53b542..78f2cc2 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.xtimms.shiki.ui.theme.SEED +import org.xtimms.tokusho.ui.theme.SEED import org.xtimms.tokusho.R import org.xtimms.tokusho.ui.monet.PaletteStyle import org.xtimms.tokusho.utils.lang.processLifecycleScope @@ -36,6 +36,10 @@ const val PRE_RELEASE = 1 const val ACRA = "acra" const val LOGGING = "logging" +const val SSL_BYPASS = "ssl_bypass" +const val NSFW = "nsfw" +const val TABS_MANGA_COUNT = "tabs_manga_count" + val paletteStyles = listOf( PaletteStyle.TonalSpot, PaletteStyle.Spritz, @@ -91,12 +95,18 @@ object AppSettings { fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false) - fun isACRAEnabled() = ACRA.getBoolean(false) + fun isACRAEnabled() = ACRA.getBoolean(true) fun isLoggingEnabled() = LOGGING.getBoolean(false) fun isReadingTimeEstimationEnabled() = READING_TIME.getBoolean(true) + fun isNSFWEnabled() = NSFW.getBoolean(false) + + fun isSSLBypassEnabled() = SSL_BYPASS.getBoolean(false) + + fun isMangaCountInTabsEnabled() = TABS_MANGA_COUNT.getBoolean(false) + fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) = languageMap.getOrElse(languageNumber) { "" } diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt new file mode 100644 index 0000000..6c4fd9a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/KotatsuAppSettings.kt @@ -0,0 +1,89 @@ +package org.xtimms.tokusho.core.prefs + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder +import org.xtimms.tokusho.utils.system.getEnumValue +import org.xtimms.tokusho.utils.system.putEnumValue +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class KotatsuAppSettings @Inject constructor(@ApplicationContext context: Context) { + + private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + + var isNsfwContentDisabled: Boolean + get() = prefs.getBoolean(KEY_DISABLE_NSFW, false) + set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) } + + val isNewSourcesTipEnabled: Boolean + get() = prefs.getBoolean(KEY_SOURCES_NEW, true) + + var sourcesSortOrder: SourcesSortOrder + get() = prefs.getEnumValue(KEY_SOURCES_ORDER, SourcesSortOrder.MANUAL) + set(value) = prefs.edit { putEnumValue(KEY_SOURCES_ORDER, value) } + + fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + prefs.registerOnSharedPreferenceChangeListener(listener) + } + + fun unsubscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + + fun observe() = prefs.observe() + + companion object { + const val KEY_DISABLE_NSFW = "no_nsfw" + const val KEY_SOURCES_NEW = "sources_new" + const val KEY_SOURCES_ORDER = "sources_sort_order" + } +} + +fun KotatsuAppSettings.observeAsFlow(key: String, valueProducer: KotatsuAppSettings.() -> T) = flow { + var lastValue: T = valueProducer() + emit(lastValue) + observe().collect { + if (it == key) { + val value = valueProducer() + if (value != lastValue) { + emit(value) + } + lastValue = value + } + } +} + +fun KotatsuAppSettings.observeAsStateFlow( + scope: CoroutineScope, + key: String, + valueProducer: KotatsuAppSettings.() -> T, +): StateFlow = observe().transform { + if (it == key) { + emit(valueProducer()) + } +}.stateIn(scope, SharingStarted.Eagerly, valueProducer()) + +fun SharedPreferences.observe(): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + trySendBlocking(key) + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { + unregisterOnSharedPreferenceChangeListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt index 60f2c94..f11030a 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt @@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach import kotlinx.collections.immutable.ImmutableList import org.xtimms.tokusho.core.components.ActionButton @@ -36,12 +39,16 @@ data class EmptyScreenAction( @Composable fun EmptyScreen( + icon: ImageVector, @StringRes title: Int, + @StringRes description: Int, modifier: Modifier = Modifier, actions: ImmutableList? = null, ) { EmptyScreen( + icon = icon, message = stringResource(title), + summary = stringResource(description), modifier = modifier, actions = actions, ) @@ -49,11 +56,12 @@ fun EmptyScreen( @Composable fun EmptyScreen( + icon: ImageVector, message: String, + summary: String, modifier: Modifier = Modifier, actions: ImmutableList? = null, ) { - val face = remember { getRandomErrorFace() } Column( modifier = modifier .fillMaxSize() @@ -62,20 +70,27 @@ fun EmptyScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Text( - text = face, - modifier = Modifier.secondaryItemAlpha(), - style = MaterialTheme.typography.displayMedium, - ) - } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(96.dp).secondaryItemAlpha() + ) Text( text = message, modifier = Modifier .paddingFromBaseline(top = 24.dp) .secondaryItemAlpha(), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + + Text( + text = summary, + modifier = Modifier + .paddingFromBaseline(top = 24.dp) + .secondaryItemAlpha(), + style = MaterialTheme.typography.titleSmall, textAlign = TextAlign.Center, ) @@ -97,16 +112,3 @@ fun EmptyScreen( } } } - -private val ErrorFaces = listOf( - "(・o・;)", - "Σ(ಠ_ಠ)", - "ಥ_ಥ", - "(˘・_・˘)", - "(; ̄Д ̄)", - "(・Д・。", -) - -private fun getRandomErrorFace(): String { - return ErrorFaces[Random.nextInt(ErrorFaces.size)] -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt index 11dd6ba..07de30c 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt @@ -31,8 +31,8 @@ import java.util.regex.Pattern object Updater { - private const val OWNER = "ztimms73" - private const val REPO = "Tokusho" + private const val OWNER = "KotatsuApp" + private const val REPO = "Kotatsu" private const val TAG = "Updates" private val client = OkHttpClient() diff --git a/app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt b/app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt new file mode 100644 index 0000000..2f82d7b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/zip/ZipOutput.kt @@ -0,0 +1,122 @@ +package org.xtimms.tokusho.core.zip + +import androidx.annotation.WorkerThread +import androidx.collection.ArraySet +import okio.Closeable +import org.xtimms.tokusho.utils.system.children +import java.io.File +import java.io.FileInputStream +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +class ZipOutput( + val file: File, + compressionLevel: Int = Deflater.DEFAULT_COMPRESSION, +) : Closeable { + + private val entryNames = ArraySet() + private var isClosed = false + private val output = ZipOutputStream(file.outputStream()).apply { + setLevel(compressionLevel) + } + + @WorkerThread + fun put(name: String, file: File): Boolean { + return output.appendFile(file, name) + } + + @WorkerThread + fun put(name: String, content: String): Boolean { + return output.appendText(content, name) + } + + @WorkerThread + fun addDirectory(name: String): Boolean { + val entry = if (name.endsWith("/")) { + ZipEntry(name) + } else { + ZipEntry("$name/") + } + return if (entryNames.add(entry.name)) { + output.putNextEntry(entry) + output.closeEntry() + true + } else { + false + } + } + + @WorkerThread + fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean { + return if (entryNames.add(entry.name)) { + val zipEntry = ZipEntry(entry.name) + output.putNextEntry(zipEntry) + try { + other.getInputStream(entry).use { input -> + input.copyTo(output) + } + } finally { + output.closeEntry() + } + true + } else { + false + } + } + + fun finish() { + output.finish() + output.flush() + } + + override fun close() { + if (!isClosed) { + output.close() + isClosed = true + } + } + + @WorkerThread + private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean { + if (fileToZip.isDirectory) { + val entry = if (name.endsWith("/")) { + ZipEntry(name) + } else { + ZipEntry("$name/") + } + if (!entryNames.add(entry.name)) { + return false + } + putNextEntry(entry) + closeEntry() + fileToZip.children().forEach { childFile -> + appendFile(childFile, "$name/${childFile.name}") + } + } else { + FileInputStream(fileToZip).use { fis -> + if (!entryNames.add(name)) { + return false + } + val zipEntry = ZipEntry(name) + putNextEntry(zipEntry) + fis.copyTo(this) + closeEntry() + } + } + return true + } + + @WorkerThread + private fun ZipOutputStream.appendText(content: String, name: String): Boolean { + if (!entryNames.add(name)) { + return false + } + val zipEntry = ZipEntry(name) + putNextEntry(zipEntry) + content.byteInputStream().copyTo(this) + closeEntry() + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt index 6238d32..f2c5edd 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt @@ -59,6 +59,28 @@ class LocalStorageManager @Inject constructor( getCacheDirs(cache.dir).forEach { it.deleteRecursively() } } + suspend fun getReadableDirs(): List = runInterruptible(Dispatchers.IO) { + getConfiguredStorageDirs() + .filter { it.isReadable() } + } + + suspend fun getWriteableDirs(): List = runInterruptible(Dispatchers.IO) { + getConfiguredStorageDirs() + .filter { it.isWriteable() } + } + + suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) { + val preferredDir = context.filesDir?.takeIf { it.isWriteable() } + preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() } + } + + @WorkerThread + private fun getConfiguredStorageDirs(): MutableSet { + val set = getAvailableStorageDirs() + set.addAll(setOf(context.filesDir)) + return set + } + @WorkerThread private fun getAvailableStorageDirs(): MutableSet { val result = LinkedHashSet() @@ -103,4 +125,11 @@ class LocalStorageManager @Inject constructor( } } + private fun File.isReadable() = runCatching { + canRead() + }.getOrDefault(false) + + private fun File.isWriteable() = runCatching { + canWrite() + }.getOrDefault(false) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt new file mode 100644 index 0000000..8ee9e0d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/BookmarksRepository.kt @@ -0,0 +1,102 @@ +package org.xtimms.tokusho.data.repository + +import android.database.SQLException +import androidx.room.withTransaction +import dagger.Reusable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.entity.BookmarkEntity +import org.xtimms.tokusho.core.database.entity.toBookmark +import org.xtimms.tokusho.core.database.entity.toBookmarks +import org.xtimms.tokusho.core.database.entity.toEntities +import org.xtimms.tokusho.core.database.entity.toEntity +import org.xtimms.tokusho.core.database.entity.toManga +import org.xtimms.tokusho.core.model.Bookmark +import org.xtimms.tokusho.utils.ReversibleHandle +import org.xtimms.tokusho.utils.lang.mapItems +import javax.inject.Inject + +@Reusable +class BookmarksRepository @Inject constructor( + private val db: TokushoDatabase, +) { + + fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow { + return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) } + } + + fun observeBookmarks(manga: Manga): Flow> { + return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) } + } + + fun observeBookmarks(): Flow>> { + return db.getBookmarksDao().observe().map { map -> + val res = LinkedHashMap>(map.size) + for ((k, v) in map) { + val manga = k.toManga() + res[manga] = v.toBookmarks(manga) + } + res + } + } + + suspend fun addBookmark(bookmark: Bookmark) { + db.withTransaction { + val tags = bookmark.manga.tags.toEntities() + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(bookmark.manga.toEntity(), tags) + db.getBookmarksDao().insert(bookmark.toEntity()) + } + } + + suspend fun updateBookmark(bookmark: Bookmark, imageUrl: String) { + val entity = bookmark.toEntity().copy( + imageUrl = imageUrl, + ) + db.getBookmarksDao().upsert(listOf(entity)) + } + + suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { + check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) { + "Bookmark not found" + } + } + + suspend fun removeBookmark(bookmark: Bookmark) { + removeBookmark(bookmark.manga.id, bookmark.chapterId, bookmark.page) + } + + suspend fun removeBookmarks(ids: Set): ReversibleHandle { + val entities = ArrayList(ids.size) + db.withTransaction { + val dao = db.getBookmarksDao() + for (pageId in ids) { + val e = dao.find(pageId) + if (e != null) { + entities.add(e) + } + dao.delete(pageId) + } + } + return BookmarksRestorer(entities) + } + + private inner class BookmarksRestorer( + private val entities: Collection, + ) : ReversibleHandle { + + override suspend fun reverse() { + db.withTransaction { + for (e in entities) { + try { + db.getBookmarksDao().insert(e) + } catch (e: SQLException) { + e.printStackTrace() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt new file mode 100644 index 0000000..6152648 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt @@ -0,0 +1,40 @@ +package org.xtimms.tokusho.data.repository + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.almostEquals +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.core.parser.MangaRepository +import org.xtimms.tokusho.utils.lang.asArrayList +import javax.inject.Inject + +class ExploreRepository @Inject constructor( + private val sourcesRepository: MangaSourcesRepository, + private val historyRepository: HistoryRepository, + private val mangaRepositoryFactory: MangaRepository.Factory, +) { + + private suspend fun getList( + source: MangaSource, + tags: List, + ): List = runCatchingCancellable { + val repository = mangaRepositoryFactory.create(source) + val order = repository.sortOrders.random() + val availableTags = repository.getTags() + val tag = tags.firstNotNullOfOrNull { title -> + availableTags.find { x -> x.title.almostEquals(title, 0.4f) } + } + val list = repository.getList( + offset = 0, + filter = MangaListFilter.Advanced.Builder(order) + .tags(setOfNotNull(tag)) + .build(), + ).asArrayList() + list.shuffle() + list + }.onFailure { + // TODO + }.getOrDefault(emptyList()) + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt index 8c801e0..bcc80aa 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/FavouritesRepository.kt @@ -2,6 +2,7 @@ package org.xtimms.tokusho.data.repository import androidx.room.withTransaction import dagger.Reusable +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -9,8 +10,13 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity +import org.xtimms.tokusho.core.database.entity.FavouriteEntity +import org.xtimms.tokusho.core.database.entity.toEntities +import org.xtimms.tokusho.core.database.entity.toEntity import org.xtimms.tokusho.core.database.entity.toFavouriteCategory import org.xtimms.tokusho.core.database.entity.toManga +import org.xtimms.tokusho.core.database.entity.toMangaList import org.xtimms.tokusho.core.model.FavouriteCategory import org.xtimms.tokusho.core.model.ListSortOrder import org.xtimms.tokusho.utils.ReversibleHandle @@ -22,11 +28,27 @@ class FavouritesRepository @Inject constructor( private val db: TokushoDatabase, ) { + suspend fun getAllManga(): List { + val entities = db.getFavouritesDao().findAll() + return entities.toMangaList() + } + + suspend fun getLastManga(limit: Int): List { + val entities = db.getFavouritesDao().findLast(limit) + return entities.toMangaList() + } + fun observeAll(categoryId: Long, order: ListSortOrder): Flow> { return db.getFavouritesDao().observeAll(categoryId, order) .mapItems { it.toManga() } } + suspend fun getManga(categoryId: Long): List { + val entities = db.getFavouritesDao().findAll(categoryId) + return entities.toMangaList() + } + + @OptIn(ExperimentalCoroutinesApi::class) fun observeAll(categoryId: Long): Flow> { return observeOrder(categoryId) .flatMapLatest { order -> observeAll(categoryId, order) } @@ -37,6 +59,17 @@ class FavouritesRepository @Inject constructor( .distinctUntilChanged() } + fun observeMangaCountInCategory(categoryId: Long): Flow { + return db.getFavouritesDao().observeMangaCountInCategory(categoryId) + .distinctUntilChanged() + } + + fun observeCategories(): Flow> { + return db.getFavouriteCategoriesDao().observeAll().mapItems { + it.toFavouriteCategory() + }.distinctUntilChanged() + } + fun observeCategoriesForLibrary(): Flow> { return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems { it.toFavouriteCategory() @@ -47,10 +80,34 @@ class FavouritesRepository @Inject constructor( return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() } } + suspend fun getCategory(id: Long): FavouriteCategory { + return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory() + } + suspend fun getCategoriesIds(mangaIds: Collection): Set { return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() } + suspend fun createCategory( + title: String, + sortOrder: ListSortOrder, + isTrackerEnabled: Boolean, + isVisibleOnShelf: Boolean, + ): FavouriteCategory { + val entity = FavouriteCategoryEntity( + title = title, + createdAt = System.currentTimeMillis(), + sortKey = db.getFavouriteCategoriesDao().getNextSortKey(), + categoryId = 0, + order = sortOrder.name, + track = isTrackerEnabled, + deletedAt = 0L, + isVisibleInLibrary = isVisibleOnShelf, + ) + val id = db.getFavouriteCategoriesDao().insert(entity) + return entity.toFavouriteCategory(id) + } + suspend fun updateCategory( id: Long, title: String, @@ -91,6 +148,24 @@ class FavouritesRepository @Inject constructor( } } + suspend fun addToCategory(categoryId: Long, mangas: Collection) { + db.withTransaction { + for (manga in mangas) { + val tags = manga.tags.toEntities() + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(manga.toEntity(), tags) + val entity = FavouriteEntity( + mangaId = manga.id, + categoryId = categoryId, + createdAt = System.currentTimeMillis(), + sortKey = 0, + deletedAt = 0L, + ) + db.getFavouritesDao().insert(entity) + } + } + } + suspend fun removeFromFavourites(ids: Collection): ReversibleHandle { db.withTransaction { for (id in ids) { diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt index b5339ea..44df9c0 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt @@ -1,14 +1,20 @@ package org.xtimms.tokusho.data.repository +import androidx.room.withTransaction import dagger.Reusable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.parsers.model.Manga import org.xtimms.tokusho.core.database.TokushoDatabase import org.xtimms.tokusho.core.database.entity.HistoryEntity +import org.xtimms.tokusho.core.database.entity.toManga import org.xtimms.tokusho.core.database.entity.toMangaHistory +import org.xtimms.tokusho.core.database.entity.toMangaTags import org.xtimms.tokusho.core.model.MangaHistory import org.xtimms.tokusho.core.model.findById +import org.xtimms.tokusho.core.model.isNsfw +import org.xtimms.tokusho.core.parser.MangaDataRepository +import org.xtimms.tokusho.utils.lang.mapItems import javax.inject.Inject const val PROGRESS_NONE = -1f @@ -16,8 +22,26 @@ const val PROGRESS_NONE = -1f @Reusable class HistoryRepository @Inject constructor( private val db: TokushoDatabase, + private val mangaRepository: MangaDataRepository, ) { + suspend fun getLastOrNull(): Manga? { + val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null + return entity.manga.toManga(entity.tags.toMangaTags()) + } + + fun observeAll(): Flow> { + return db.getHistoryDao().observeAll().mapItems { + it.manga.toManga(it.tags.toMangaTags()) + } + } + + fun observeAll(limit: Int): Flow> { + return db.getHistoryDao().observeAll(limit).mapItems { + it.manga.toManga(it.tags.toMangaTags()) + } + } + suspend fun getOne(manga: Manga): MangaHistory? { return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory() } @@ -28,6 +52,31 @@ class HistoryRepository @Inject constructor( } } + suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) { + if (shouldSkip(manga)) { + return + } + db.withTransaction { + mangaRepository.storeManga(manga) + db.getHistoryDao().upsert( + HistoryEntity( + mangaId = manga.id, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + chapterId = chapterId, + page = page, + scroll = scroll.toFloat(), // we migrate to int, but decide to not update database + percent = percent, + deletedAt = 0L, + ), + ) + } + } + + fun shouldSkip(manga: Manga): Boolean { + return ((manga.source.isNsfw() || manga.isNsfw)) + } + private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity { val chapters = manga.chapters if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) { diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt index fc3b94a..f279cb3 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt @@ -1,17 +1,39 @@ package org.xtimms.tokusho.data.repository +import androidx.compose.runtime.Composable import dagger.Reusable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet import org.xtimms.tokusho.BuildConfig import org.xtimms.tokusho.core.database.TokushoDatabase import org.xtimms.tokusho.core.database.dao.MangaSourcesDao +import org.xtimms.tokusho.core.database.entity.MangaSourceEntity +import org.xtimms.tokusho.core.model.MangaSource +import org.xtimms.tokusho.core.model.isNsfw +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.KotatsuAppSettings +import org.xtimms.tokusho.core.prefs.observeAsFlow +import org.xtimms.tokusho.sections.explore.data.SourcesSortOrder +import org.xtimms.tokusho.utils.ReversibleHandle import java.util.Collections import java.util.EnumSet import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @Reusable class MangaSourcesRepository @Inject constructor( private val db: TokushoDatabase, + private val settings: KotatsuAppSettings, ) { private val dao: MangaSourcesDao @@ -27,4 +49,95 @@ class MangaSourcesRepository @Inject constructor( val allMangaSources: Set get() = Collections.unmodifiableSet(remoteSources) + suspend fun getEnabledSources(): List { + val order = settings.sourcesSortOrder + return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled) + } + + suspend fun getDisabledSources(): List { + return dao.findAllDisabled().toSources(settings.isNsfwContentDisabled) + } + + fun observeEnabledSourcesCount(): Flow { + return combine( + observeIsNsfwDisabled(), + dao.observeEnabled(SourcesSortOrder.MANUAL), + ) { skipNsfw, sources -> + sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() } + }.distinctUntilChanged() + } + + fun observeAvailableSourcesCount(): Flow { + return combine( + observeIsNsfwDisabled(), + dao.observeEnabled(SourcesSortOrder.MANUAL), + ) { skipNsfw, enabledSources -> + val enabled = enabledSources.mapToSet { it.source } + allMangaSources.count { x -> + x.name !in enabled && (!skipNsfw || !x.isNsfw()) + } + }.distinctUntilChanged() + } + + fun observeEnabledSources(): Flow> = combine( + observeIsNsfwDisabled(), + observeSortOrder(), + ) { skipNsfw, order -> + dao.observeEnabled(order).map { + it.toSources(skipNsfw) + } + }.flatMapLatest { it } + + suspend fun setSourceEnabled(source: MangaSource, isEnabled: Boolean): ReversibleHandle { + dao.setEnabled(source.name, isEnabled) + return ReversibleHandle { + dao.setEnabled(source.name, !isEnabled) + } + } + + fun observeNewSources(): Flow> = observeIsNewSourcesEnabled().flatMapLatest { + if (it) { + combine( + dao.observeAll(), + observeIsNsfwDisabled(), + ) { entities, skipNsfw -> + val result = EnumSet.copyOf(remoteSources) + for (e in entities) { + result.remove(MangaSource(e.source)) + } + if (skipNsfw) { + result.removeAll { x -> x.isNsfw() } + } + result + }.distinctUntilChanged() + } else { + flowOf(emptySet()) + } + } + + private fun List.toSources( + skipNsfwSources: Boolean, + ): List { + val result = ArrayList(size) + for (entity in this) { + val source = MangaSource(entity.source) + if (skipNsfwSources && source.contentType == ContentType.HENTAI) { + continue + } + if (source in remoteSources) { + result.add(source) + } + } + return result + } + + private fun observeIsNsfwDisabled() = MutableStateFlow(AppSettings.isNSFWEnabled()).asStateFlow() + + private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_NEW) { + isNewSourcesTipEnabled + } + + private fun observeSortOrder() = settings.observeAsFlow(KotatsuAppSettings.KEY_SOURCES_ORDER) { + sourcesSortOrder + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt new file mode 100644 index 0000000..d4c12f4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupEntry.kt @@ -0,0 +1,21 @@ +package org.xtimms.tokusho.data.repository.backup + +import org.json.JSONArray + +class BackupEntry( + val name: Name, + val data: JSONArray +) { + + enum class Name( + val key: String, + ) { + + INDEX("index"), + HISTORY("history"), + CATEGORIES("categories"), + FAVOURITES("favourites"), + BOOKMARKS("bookmarks"), + SOURCES("sources"), + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt new file mode 100644 index 0000000..5b0b3c7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupRepository.kt @@ -0,0 +1,200 @@ +package org.xtimms.tokusho.data.repository.backup + +import androidx.room.withTransaction +import org.json.JSONArray +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.util.json.JSONIterator +import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault +import org.koitharu.kotatsu.parsers.util.json.mapJSON +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.BuildConfig +import org.xtimms.tokusho.core.database.TokushoDatabase +import java.util.Date +import javax.inject.Inject + +private const val PAGE_SIZE = 10 + +class BackupRepository @Inject constructor( + private val db: TokushoDatabase, +) { + + suspend fun dumpHistory(): BackupEntry { + var offset = 0 + val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) + while (true) { + val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) + if (history.isEmpty()) { + break + } + offset += history.size + for (item in history) { + val manga = JsonSerializer(item.manga).toJson() + val tags = JSONArray() + item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } + manga.put("tags", tags) + val json = JsonSerializer(item.history).toJson() + json.put("manga", manga) + entry.data.put(json) + } + } + return entry + } + + suspend fun dumpCategories(): BackupEntry { + val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray()) + val categories = db.getFavouriteCategoriesDao().findAll() + for (item in categories) { + entry.data.put(JsonSerializer(item).toJson()) + } + return entry + } + + suspend fun dumpFavourites(): BackupEntry { + var offset = 0 + val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) + while (true) { + val favourites = db.getFavouritesDao().findAllRaw(offset, PAGE_SIZE) + if (favourites.isEmpty()) { + break + } + offset += favourites.size + for (item in favourites) { + val manga = JsonSerializer(item.manga).toJson() + val tags = JSONArray() + item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } + manga.put("tags", tags) + val json = JsonSerializer(item.favourite).toJson() + json.put("manga", manga) + entry.data.put(json) + } + } + return entry + } + + suspend fun dumpBookmarks(): BackupEntry { + val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) + val all = db.getBookmarksDao().findAll() + for ((m, b) in all) { + val json = JSONObject() + val manga = JsonSerializer(m.manga).toJson() + json.put("manga", manga) + val tags = JSONArray() + m.tags.forEach { tags.put(JsonSerializer(it).toJson()) } + json.put("tags", tags) + val bookmarks = JSONArray() + b.forEach { bookmarks.put(JsonSerializer(it).toJson()) } + json.put("bookmarks", bookmarks) + entry.data.put(json) + } + return entry + } + + suspend fun dumpSources(): BackupEntry { + val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray()) + val all = db.getSourcesDao().findAll() + for (source in all) { + val json = JsonSerializer(source).toJson() + entry.data.put(json) + } + return entry + } + + fun createIndex(): BackupEntry { + val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray()) + val json = JSONObject() + json.put("app_id", BuildConfig.APPLICATION_ID) + json.put("app_version", BuildConfig.VERSION_CODE) + json.put("created_at", System.currentTimeMillis()) + entry.data.put(json) + return entry + } + + fun getBackupDate(entry: BackupEntry?): Date? { + val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0 + return if (timestamp == 0L) null else Date(timestamp) + } + + suspend fun restoreHistory(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val mangaJson = item.getJSONObject("manga") + val manga = JsonDeserializer(mangaJson).toMangaEntity() + val tags = mangaJson.getJSONArray("tags").mapJSON { + JsonDeserializer(it).toTagEntity() + } + val history = JsonDeserializer(item).toHistoryEntity() + result += runCatchingCancellable { + db.withTransaction { + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(manga, tags) + db.getHistoryDao().upsert(history) + } + } + } + return result + } + + suspend fun restoreCategories(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val category = JsonDeserializer(item).toFavouriteCategoryEntity() + result += runCatchingCancellable { + db.getFavouriteCategoriesDao().upsert(category) + } + } + return result + } + + suspend fun restoreFavourites(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val mangaJson = item.getJSONObject("manga") + val manga = JsonDeserializer(mangaJson).toMangaEntity() + val tags = mangaJson.getJSONArray("tags").mapJSON { + JsonDeserializer(it).toTagEntity() + } + val favourite = JsonDeserializer(item).toFavouriteEntity() + result += runCatchingCancellable { + db.withTransaction { + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(manga, tags) + db.getFavouritesDao().upsert(favourite) + } + } + } + return result + } + + suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val mangaJson = item.getJSONObject("manga") + val manga = JsonDeserializer(mangaJson).toMangaEntity() + val tags = item.getJSONArray("tags").mapJSON { + JsonDeserializer(it).toTagEntity() + } + val bookmarks = item.getJSONArray("bookmarks").mapJSON { + JsonDeserializer(it).toBookmarkEntity() + } + result += runCatchingCancellable { + db.withTransaction { + db.getTagsDao().upsert(tags) + db.getMangaDao().upsert(manga, tags) + db.getBookmarksDao().upsert(bookmarks) + } + } + } + return result + } + + suspend fun restoreSources(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val source = JsonDeserializer(item).toMangaSourceEntity() + result += runCatchingCancellable { + db.getSourcesDao().upsert(source) + } + } + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt new file mode 100644 index 0000000..54e11bf --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipInput.kt @@ -0,0 +1,44 @@ +package org.xtimms.tokusho.data.repository.backup + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.json.JSONArray +import org.xtimms.tokusho.utils.lang.processLifecycleScope +import java.io.File +import java.util.EnumSet +import java.util.zip.ZipFile + +class BackupZipInput(val file: File) : Closeable { + + private val zipFile = ZipFile(file) + + suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) { + val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null + val json = zipFile.getInputStream(entry).use { + JSONArray(it.bufferedReader().readText()) + } + BackupEntry(name, json) + } + + suspend fun entries(): Set = runInterruptible(Dispatchers.IO) { + zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze -> + BackupEntry.Name.entries.find { it.key == ze.name } + } + } + + override fun close() { + zipFile.close() + } + + fun cleanupAsync() { + processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { + runCatching { + close() + file.delete() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt new file mode 100644 index 0000000..4a0a8d3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/BackupZipOutput.kt @@ -0,0 +1,46 @@ +package org.xtimms.tokusho.data.repository.backup + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import okio.Closeable +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.zip.ZipOutput +import java.io.File +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.zip.Deflater + +class BackupZipOutput(val file: File) : Closeable { + + private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) + + suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { + output.put(entry.name.key, entry.data.toString(2)) + } + + suspend fun finish() = runInterruptible(Dispatchers.IO) { + output.finish() + } + + override fun close() { + output.close() + } +} + +const val DIR_BACKUPS = "backups" + +suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { + val dir = context.run { + getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) + } + dir.mkdirs() + val filename = buildString { + append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) + append('_') + append(LocalDate.now().format(DateTimeFormatter.ofPattern("ddMMyyyy"))) + append(".bk.zip") + } + BackupZipOutput(File(dir, filename)) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt new file mode 100644 index 0000000..5cda730 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/CompositeResult.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.data.repository.backup + +class CompositeResult { + + private var successCount: Int = 0 + private val errors = ArrayList() + + val size: Int + get() = successCount + errors.size + + val failures: List + get() = errors.filterNotNull() + + val isEmpty: Boolean + get() = errors.isEmpty() && successCount == 0 + + val isAllSuccess: Boolean + get() = errors.none { it != null } + + val isAllFailed: Boolean + get() = successCount == 0 && errors.isNotEmpty() + + operator fun plusAssign(result: Result<*>) { + when { + result.isSuccess -> successCount++ + result.isFailure -> errors.add(result.exceptionOrNull()) + } + } + + operator fun plusAssign(other: CompositeResult) { + this.successCount += other.successCount + this.errors += other.errors + } + + operator fun plus(other: CompositeResult): CompositeResult { + val result = CompositeResult() + result.successCount = this.successCount + other.successCount + result.errors.addAll(this.errors) + result.errors.addAll(other.errors) + return result + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt new file mode 100644 index 0000000..e51dc1b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonDeserializer.kt @@ -0,0 +1,100 @@ +package org.xtimms.tokusho.data.repository.backup + +import org.json.JSONObject +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault +import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault +import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.xtimms.tokusho.core.database.entity.BookmarkEntity +import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity +import org.xtimms.tokusho.core.database.entity.FavouriteEntity +import org.xtimms.tokusho.core.database.entity.HistoryEntity +import org.xtimms.tokusho.core.database.entity.MangaEntity +import org.xtimms.tokusho.core.database.entity.MangaSourceEntity +import org.xtimms.tokusho.core.database.entity.TagEntity + +class JsonDeserializer(private val json: JSONObject) { + + fun toFavouriteEntity() = FavouriteEntity( + mangaId = json.getLong("manga_id"), + categoryId = json.getLong("category_id"), + sortKey = json.getIntOrDefault("sort_key", 0), + createdAt = json.getLong("created_at"), + deletedAt = 0L, + ) + + fun toMangaEntity() = MangaEntity( + id = json.getLong("id"), + title = json.getString("title"), + altTitle = json.getStringOrNull("alt_title"), + url = json.getString("url"), + publicUrl = json.getStringOrNull("public_url").orEmpty(), + rating = json.getDouble("rating").toFloat(), + isNsfw = json.getBooleanOrDefault("nsfw", false), + coverUrl = json.getString("cover_url"), + largeCoverUrl = json.getStringOrNull("large_cover_url"), + state = json.getStringOrNull("state"), + author = json.getStringOrNull("author"), + source = json.getString("source"), + ) + + fun toTagEntity() = TagEntity( + id = json.getLong("id"), + title = json.getString("title"), + key = json.getString("key"), + source = json.getString("source"), + ) + + fun toHistoryEntity() = HistoryEntity( + mangaId = json.getLong("manga_id"), + createdAt = json.getLong("created_at"), + updatedAt = json.getLong("updated_at"), + chapterId = json.getLong("chapter_id"), + page = json.getInt("page"), + scroll = json.getDouble("scroll").toFloat(), + percent = json.getFloatOrDefault("percent", -1f), + deletedAt = 0L, + ) + + fun toFavouriteCategoryEntity() = FavouriteCategoryEntity( + categoryId = json.getInt("category_id"), + createdAt = json.getLong("created_at"), + sortKey = json.getInt("sort_key"), + title = json.getString("title"), + order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, + track = json.getBooleanOrDefault("track", true), + isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true), + deletedAt = 0L, + ) + + fun toBookmarkEntity() = BookmarkEntity( + mangaId = json.getLong("manga_id"), + pageId = json.getLong("page_id"), + chapterId = json.getLong("chapter_id"), + page = json.getInt("page"), + scroll = json.getInt("scroll"), + imageUrl = json.getString("image_url"), + createdAt = json.getLong("created_at"), + percent = json.getDouble("percent").toFloat(), + ) + + fun toMangaSourceEntity() = MangaSourceEntity( + source = json.getString("source"), + isEnabled = json.getBoolean("enabled"), + sortKey = json.getInt("sort_key"), + ) + + fun toMap(): Map { + val map = mutableMapOf() + val keys = json.keys() + + while (keys.hasNext()) { + val key = keys.next() + val value = json.get(key) + map[key] = value + } + + return map + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt new file mode 100644 index 0000000..9e3a221 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/backup/JsonSerializer.kt @@ -0,0 +1,99 @@ +package org.xtimms.tokusho.data.repository.backup + +import org.json.JSONObject +import org.xtimms.tokusho.core.database.entity.BookmarkEntity +import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity +import org.xtimms.tokusho.core.database.entity.FavouriteEntity +import org.xtimms.tokusho.core.database.entity.HistoryEntity +import org.xtimms.tokusho.core.database.entity.MangaEntity +import org.xtimms.tokusho.core.database.entity.MangaSourceEntity +import org.xtimms.tokusho.core.database.entity.TagEntity + +class JsonSerializer private constructor(private val json: JSONObject) { + + constructor(e: FavouriteEntity) : this( + JSONObject().apply { + put("manga_id", e.mangaId) + put("category_id", e.categoryId) + put("sort_key", e.sortKey) + put("created_at", e.createdAt) + }, + ) + + constructor(e: FavouriteCategoryEntity) : this( + JSONObject().apply { + put("category_id", e.categoryId) + put("created_at", e.createdAt) + put("sort_key", e.sortKey) + put("title", e.title) + put("order", e.order) + put("track", e.track) + put("show_in_lib", e.isVisibleInLibrary) + }, + ) + + constructor(e: HistoryEntity) : this( + JSONObject().apply { + put("manga_id", e.mangaId) + put("created_at", e.createdAt) + put("updated_at", e.updatedAt) + put("chapter_id", e.chapterId) + put("page", e.page) + put("scroll", e.scroll) + put("percent", e.percent) + }, + ) + + constructor(e: TagEntity) : this( + JSONObject().apply { + put("id", e.id) + put("title", e.title) + put("key", e.key) + put("source", e.source) + }, + ) + + constructor(e: MangaEntity) : this( + JSONObject().apply { + put("id", e.id) + put("title", e.title) + put("alt_title", e.altTitle) + put("url", e.url) + put("public_url", e.publicUrl) + put("rating", e.rating) + put("nsfw", e.isNsfw) + put("cover_url", e.coverUrl) + put("large_cover_url", e.largeCoverUrl) + put("state", e.state) + put("author", e.author) + put("source", e.source) + }, + ) + + constructor(e: BookmarkEntity) : this( + JSONObject().apply { + put("manga_id", e.mangaId) + put("page_id", e.pageId) + put("chapter_id", e.chapterId) + put("page", e.page) + put("scroll", e.scroll) + put("image_url", e.imageUrl) + put("created_at", e.createdAt) + put("percent", e.percent) + }, + ) + + constructor(e: MangaSourceEntity) : this( + JSONObject().apply { + put("source", e.source) + put("enabled", e.isEnabled) + put("sort_key", e.sortKey) + }, + ) + + constructor(m: Map) : this( + JSONObject(m), + ) + + fun toJson(): JSONObject = json +} diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt new file mode 100644 index 0000000..59eecd2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/ChapterListItem.kt @@ -0,0 +1,127 @@ +package org.xtimms.tokusho.sections.details + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.DotSeparatorText +import org.xtimms.tokusho.utils.composable.selectedBackground +import org.xtimms.tokusho.utils.material.SecondaryItemAlpha + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChapterListItem( + title: String, + date: Long?, + scanlator: String?, + read: Boolean, + bookmark: Boolean, + selected: Boolean, + onLongClick: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + + val textAlpha = if (read) .38f else 1f + val textSubtitleAlpha = if (read) .38f else SecondaryItemAlpha + + Box( + modifier = Modifier.clipToBounds() + ) { + Row( + modifier = modifier + .selectedBackground(selected) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var textHeight by remember { mutableIntStateOf(0) } + if (!read) { + Icon( + imageVector = Icons.Filled.Circle, + contentDescription = stringResource(R.string.unread), + modifier = Modifier + .height(8.dp) + .padding(end = 4.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + if (bookmark) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = stringResource(R.string.action_filter_bookmarked), + modifier = Modifier + .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = textAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { textHeight = it.size.height }, + ) + } + + Row(modifier = Modifier.alpha(textSubtitleAlpha)) { + ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { + if (date != null) { + Text( + text = date.toString(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (scanlator != null) DotSeparatorText() + } + if (scanlator != null) { + Text( + text = scanlator, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt new file mode 100644 index 0000000..0d15011 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/ChaptersMapper.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.sections.details + +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.xtimms.tokusho.core.model.Bookmark +import org.xtimms.tokusho.core.model.MangaHistory +import org.xtimms.tokusho.sections.details.data.MangaDetails +import org.xtimms.tokusho.sections.details.model.ChapterItem +import org.xtimms.tokusho.sections.details.model.toListItem + +fun MangaDetails.mapChapters( + history: MangaHistory?, + newCount: Int, + branch: String?, + bookmarks: List, +): List { + val remoteChapters = chapters[branch].orEmpty() + val localChapters = local?.manga?.getChapters(branch).orEmpty() + if (remoteChapters.isEmpty() && localChapters.isEmpty()) { + return emptyList() + } + val bookmarked = bookmarks.mapToSet { it.chapterId } + val currentId = history?.chapterId ?: 0L + val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount + val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) { + remoteChapters.mapTo(this) { it.id } + localChapters.mapTo(this) { it.id } + } + val result = ArrayList(ids.size) + val localMap = if (localChapters.isNotEmpty()) { + localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } + } else { + null + } + var isUnread = currentId !in ids + for (chapter in remoteChapters) { + val local = localMap?.remove(chapter.id) + if (chapter.id == currentId) { + isUnread = true + } + result += (local ?: chapter).toListItem( + isCurrent = chapter.id == currentId, + isUnread = isUnread, + isNew = isUnread && result.size >= newFrom, + isDownloaded = local != null, + isBookmarked = chapter.id in bookmarked, + ) + } + if (!localMap.isNullOrEmpty()) { + for (chapter in localMap.values) { + if (chapter.id == currentId) { + isUnread = true + } + result += chapter.toListItem( + isCurrent = chapter.id == currentId, + isUnread = isUnread, + isNew = false, + isDownloaded = !isLocal, + isBookmarked = chapter.id in bookmarked, + ) + } + } + return result +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt index 60f0cdd..e4ca197 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt @@ -1,47 +1,78 @@ package org.xtimms.tokusho.sections.details +import android.net.Uri import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.ChipDefaults +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.outlined.Block -import androidx.compose.material.icons.outlined.Brush import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.LocalLibrary import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Upcoming +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ChipColors import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text @@ -54,29 +85,39 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil.ImageLoader import coil.compose.AsyncImage +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.AsyncImageImpl +import org.xtimms.tokusho.core.components.AnimatedButton +import org.xtimms.tokusho.core.components.ButtonType import org.xtimms.tokusho.core.components.MangaCover +import org.xtimms.tokusho.core.components.MangaHorizontalItem +import org.xtimms.tokusho.core.components.ReadButton +import org.xtimms.tokusho.core.parser.favicon.faviconUri import org.xtimms.tokusho.ui.theme.TokushoTheme +import org.xtimms.tokusho.ui.theme.applyOpacity +import org.xtimms.tokusho.ui.theme.disabledIconOpacity import org.xtimms.tokusho.utils.composable.clickableNoIndication import org.xtimms.tokusho.utils.composable.secondaryItemAlpha import kotlin.math.roundToInt @@ -87,33 +128,42 @@ private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTIL fun DetailsInfoBox( coil: ImageLoader, imageUrl: String, + favicon: Uri, title: String, - author: String?, + altTitle: String, + score: Float, + author: String, artist: String?, + isNsfw: Boolean, state: MangaState?, + source: MangaSource, + chapters: String?, isTabletUi: Boolean, appBarPadding: Dp, modifier: Modifier = Modifier, + onCoverClick: () -> Unit, + isInShelf: Boolean, + onAddToShelfClicked: () -> Unit, + onSourceClicked: () -> Unit, ) { - Box(modifier = modifier) { + Column(modifier = modifier) { val backdropGradientColors = listOf( Color.Transparent, MaterialTheme.colorScheme.background, ) - AsyncImage( + AsyncImageImpl( + coil = coil, model = imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier - .matchParentSize() - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient(colors = backdropGradientColors), - ) - } - .blur(2.dp) - .alpha(0.2f) + .padding(start = 16.dp, end = 16.dp) + .aspectRatio(1f) + .clickable( + role = Role.Button, + onClick = onCoverClick + ) + .clip(MaterialTheme.shapes.large) ) CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { @@ -122,20 +172,37 @@ fun DetailsInfoBox( coil = coil, appBarPadding = appBarPadding, imageUrl = imageUrl, + favicon = favicon, title = title, + altTitle = altTitle, + score = score, author = author, artist = artist, - state = state + isNsfw = isNsfw, + state = state, + source = source, + chapters = chapters, + isInShelf = isInShelf, + onAddToShelfClicked = onAddToShelfClicked, + onSourceClicked = onSourceClicked ) } else { MangaAndSourceTitlesLarge( coil = coil, appBarPadding = appBarPadding, imageUrl = imageUrl, + favicon = favicon, title = title, + altTitle = altTitle, + score = score, author = author, artist = artist, - state = state + isNsfw = isNsfw, + state = state, + source = source, + isInShelf = isInShelf, + onAddToShelfClicked = onAddToShelfClicked, + onSourceClicked = onSourceClicked ) } } @@ -147,10 +214,18 @@ private fun MangaAndSourceTitlesLarge( coil: ImageLoader, appBarPadding: Dp, imageUrl: String, + favicon: Uri, title: String, - author: String?, + altTitle: String, + score: Float, + author: String, artist: String?, - state: MangaState? + isNsfw: Boolean, + source: MangaSource, + state: MangaState?, + isInShelf: Boolean, + onAddToShelfClicked: () -> Unit, + onSourceClicked: () -> Unit, ) { Column( modifier = Modifier @@ -166,9 +241,18 @@ private fun MangaAndSourceTitlesLarge( ) Spacer(modifier = Modifier.height(16.dp)) DetailsContentInfo( + coil = coil, + favicon = favicon, title = title, + altTitle = altTitle, + score = score, author = author, - artist = artist, + isNsfw = isNsfw, + state = state, + source = source.title, + isInShelf = isInShelf, + onAddToShelfClicked = onAddToShelfClicked, + onSourceClicked = onSourceClicked ) } } @@ -178,100 +262,304 @@ private fun MangaAndSourceTitlesSmall( coil: ImageLoader, appBarPadding: Dp, imageUrl: String, + favicon: Uri, title: String, - author: String?, + altTitle: String, + score: Float, + author: String, artist: String?, + isNsfw: Boolean, state: MangaState?, + source: MangaSource, + chapters: String?, + isInShelf: Boolean, + onAddToShelfClicked: () -> Unit, + onSourceClicked: () -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { - MangaCover.Book( - coil = coil, + /*AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier - .sizeIn(maxWidth = 100.dp) - .align(Alignment.Top), - data = imageUrl, - contentDescription = stringResource(R.string.manga_cover), - ) - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - DetailsContentInfo( - title = title, - author = author, - artist = artist, - ) - } - } - Row { - DetailsRow( - source = "MangaDex", - chapters = "22 chapters", - state = state + .padding(PaddingValues(bottom = 8.dp)) + .clip(RoundedCornerShape(100)) + .size(48.dp), + )*/ + DetailsContentInfo( + coil = coil, + favicon = favicon, + title = title, + altTitle = altTitle, + score = score, + author = author, + isNsfw = isNsfw, + state = state, + source = source.title, + isInShelf = isInShelf, + onAddToShelfClicked = onAddToShelfClicked, + onSourceClicked = onSourceClicked ) } } - } +@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class, + ExperimentalMaterial3Api::class +) @Composable -private fun ColumnScope.DetailsContentInfo( +private fun DetailsContentInfo( + coil: ImageLoader, + favicon: Uri, title: String, - author: String?, - artist: String?, + altTitle: String, + score: Float, + author: String, + isNsfw: Boolean, + state: MangaState?, + source: String?, + isInShelf: Boolean, + onAddToShelfClicked: () -> Unit, + onSourceClicked: () -> Unit, textAlign: TextAlign? = LocalTextStyle.current.textAlign, ) { - val context = LocalContext.current - Text( - text = title.ifBlank { stringResource(id = R.string.unknown_title) }, - style = MaterialTheme.typography.headlineSmall, - textAlign = textAlign - ) - - Spacer(modifier = Modifier.height(2.dp)) - - Row( - modifier = Modifier.secondaryItemAlpha(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Person, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Text( - text = author?.takeIf { it.isNotBlank() } - ?: stringResource(id = R.string.unknown_author), - style = MaterialTheme.typography.titleSmall, - textAlign = textAlign - ) - } - - if (!artist.isNullOrBlank() && author != artist) { - Row( - modifier = Modifier.secondaryItemAlpha(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + Row { + Column( + modifier = Modifier + .padding(end = 16.dp, start = 16.dp) ) { - Icon( - imageVector = Icons.Outlined.Brush, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) + val sourceTitle = source?.takeIf { it.isNotBlank() } + ?: stringResource(id = R.string.unknown) Text( - text = artist, - style = MaterialTheme.typography.titleSmall, + text = title.ifBlank { stringResource(id = R.string.unknown_title) }, + style = MaterialTheme.typography.headlineLarge, textAlign = textAlign, + lineHeight = 36.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 3 ) + + if (altTitle.isNotBlank()) { + Text( + text = altTitle, + style = MaterialTheme.typography.headlineSmall, + textAlign = textAlign, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + if (author.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + modifier = Modifier.size(MaterialTheme.typography.titleLarge.fontSize.value.dp), + imageVector = Icons.Outlined.Person, + contentDescription = null + ) + Text( + text = author, + style = MaterialTheme.typography.titleLarge, + textAlign = textAlign, + overflow = TextOverflow.Ellipsis, + maxLines = 2 + ) + } + Spacer(modifier = Modifier.height(4.dp)) + } + + FlowRow( + modifier = Modifier.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + InputChip( + selected = false, + onClick = { onAddToShelfClicked() }, + label = { + Text( + text = if (isInShelf) + stringResource(id = R.string.in_shelf) + else + stringResource(id = R.string.add_to_shelf), + color = if (isInShelf) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface, + ) + }, + leadingIcon = { + Icon( + modifier = Modifier.size(18.dp), + imageVector = Icons.Outlined.LocalLibrary, + contentDescription = null, + tint = if (isInShelf) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.outline + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = if (isInShelf) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.outline + ) + }, + border = BorderStroke( + 1.dp, + if (isInShelf) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ) + ) + AssistChip( + onClick = { onSourceClicked() }, + leadingIcon = { + AsyncImageImpl( + coil = coil, + modifier = Modifier + .size(18.dp) + .clip(RoundedCornerShape(100)), + model = favicon, + contentScale = ContentScale.Crop, + contentDescription = null + ) + }, + label = { Text(text = sourceTitle) }, + ) + AssistChip( + onClick = { /*TODO*/ }, + leadingIcon = { + Icon( + imageVector = when (state) { + MangaState.ONGOING -> Icons.Outlined.Schedule + MangaState.FINISHED -> Icons.Outlined.DoneAll + MangaState.ABANDONED -> Icons.Outlined.Close + MangaState.PAUSED -> Icons.Outlined.Pause + MangaState.UPCOMING -> Icons.Outlined.Upcoming + else -> Icons.Outlined.Block + }, + contentDescription = null, + modifier = Modifier + .size(MaterialTheme.typography.bodyLarge.fontSize.value.dp), + tint = MaterialTheme.colorScheme.outline + ) + }, + label = { + Text( + text = when (state) { + MangaState.ONGOING -> stringResource(id = R.string.ongoing) + MangaState.FINISHED -> stringResource(id = R.string.finished) + MangaState.ABANDONED -> stringResource(id = R.string.abandoned) + MangaState.PAUSED -> stringResource(id = R.string.paused) + MangaState.UPCOMING -> stringResource(id = R.string.upcoming) + else -> stringResource(id = R.string.unknown) + }, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + ) + if (isNsfw) { + AssistChip( + onClick = { /*TODO*/ }, + leadingIcon = { + Icon( + modifier = Modifier.size(18.dp), + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + label = { Text(text = "18+", color = MaterialTheme.colorScheme.error) }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error) + ) + } + OutlinedIconButton( + modifier = Modifier + .height(32.dp) + .width(56.dp), + onClick = { /*TODO*/ }, + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) { + val rotating by rememberInfiniteTransition("rotating").animateFloat( + label = "rotating", + initialValue = 360f, + targetValue = -360f, + animationSpec = infiniteRepeatable(tween(3000), RepeatMode.Restart) + ) + Icon( + modifier = Modifier + .size(18.dp) + .rotate(rotating), + imageVector = Icons.Outlined.Sync, + contentDescription = "", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ReadButton() + /*FilledTonalButton( + modifier = Modifier + .height(54.dp) + .weight(1f), + onClick = { /*TODO*/ } + ) { + Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(id = R.string.read)) + }*/ + + AnimatedButton( + modifier = Modifier + .size(54.dp), + type = ButtonType.TERTIARY, + icon = Icons.Outlined.FileDownload + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) } + + /*Row(modifier = Modifier + .weight(.5f) + .padding(start = 4.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AnimatedButton( + modifier = Modifier.size(54.dp), + type = KeyboardButtonType.PRIMARY, + icon = Icons.Outlined.FavoriteBorder + ) + AnimatedButton( + modifier = Modifier + .height(54.dp) + .fillMaxWidth(), + type = KeyboardButtonType.TERTIARY, + icon = Icons.Outlined.PlayArrow + ) + }*/ } } @@ -366,23 +654,37 @@ private fun RowScope.DetailsRow( fun ExpandableMangaDescription( defaultExpandState: Boolean, description: String?, - tagsProvider: () -> List?, + tagsProvider: () -> Set?, onTagSearch: (String) -> Unit, onCopyTagToClipboard: (tag: String) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { + Column(modifier = modifier.padding(start = 16.dp, end = 8.dp)) { val (expanded, onExpanded) = rememberSaveable { mutableStateOf(defaultExpandState) } val desc = - description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder) + description.takeIf { !it.isNullOrBlank() } + ?: stringResource(R.string.description_placeholder) val trimmedDescription = remember(desc) { desc .replace(whitespaceLineRegex, "\n") .trimEnd() } val tags = tagsProvider() + + Text( + text = stringResource(id = R.string.description), + style = MaterialTheme.typography.titleLarge + ) + MangaSummary( + expandedDescription = desc, + shrunkDescription = trimmedDescription, + expanded = expanded, + modifier = Modifier + .padding(top = 8.dp) + .clickableNoIndication { onExpanded(!expanded) }, + ) if (!tags.isNullOrEmpty()) { Box( modifier = Modifier @@ -410,13 +712,13 @@ fun ExpandableMangaDescription( ) } FlowRow( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { tags.forEach { TagsChip( modifier = DefaultTagChipModifier, - text = it.title, + tag = it, onClick = { tagSelected = it.title showMenu = true @@ -426,14 +728,7 @@ fun ExpandableMangaDescription( } } } - MangaSummary( - expandedDescription = desc, - shrunkDescription = trimmedDescription, - expanded = expanded, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - .clickableNoIndication { onExpanded(!expanded) }, - ) + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) } } @@ -481,7 +776,8 @@ private fun MangaSummary( modifier = Modifier.background(Brush.verticalGradient(colors = colors)), contentAlignment = Alignment.Center, ) { - val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down) + val image = + AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down) Icon( painter = rememberAnimatedVectorPainter(image, !expanded), contentDescription = stringResource( @@ -523,7 +819,7 @@ private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp) @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TagsChip( - text: String, + tag: MangaTag, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -531,7 +827,52 @@ private fun TagsChip( SuggestionChip( modifier = modifier, onClick = onClick, - label = { Text(text = text) }, + label = { Text(text = tag.title) }, ) } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun DetailsInfoBoxPreview() { + TokushoTheme { + LazyColumn() { + item { + DetailsInfoBox( + coil = ImageLoader(LocalContext.current), + appBarPadding = 0.dp, + imageUrl = "", + favicon = MangaSource.MANGADEX.faviconUri(), + title = "Yofukashi no Uta", + altTitle = "よふかしのうた", + score = 3f, + author = "Kotoyama", + artist = null, + isNsfw = true, + state = null, + source = MangaSource.MANGADEX, + chapters = "22", + isTabletUi = false, + onCoverClick = {}, + isInShelf = true, + onAddToShelfClicked = {}, + onSourceClicked = {} + ) + } + item { + ExpandableMangaDescription( + defaultExpandState = true, + description = "Test ".repeat(5), + tagsProvider = { + setOf( + MangaTag("Test", "1", MangaSource.DUMMY), + MangaTag("Test", "2", MangaSource.DUMMY) + ) + }, + onTagSearch = { }, + onCopyTagToClipboard = { } + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt index 895aed8..89e84c2 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt @@ -1,46 +1,101 @@ package org.xtimms.tokusho.sections.details +import android.net.Uri import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Timelapse +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.ImageLoader +import kotlinx.coroutines.flow.collectLatest +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.DetailsToolbar -import org.xtimms.tokusho.core.components.PreferenceItem -import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.components.MangaHorizontalItem +import org.xtimms.tokusho.core.parser.favicon.faviconUri +import org.xtimms.tokusho.utils.lang.toNavArgument const val MANGA_ID_ARGUMENT = "{mangaId}" const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT" +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DetailsView( coil: ImageLoader, + mangaId: Long, + viewModel: DetailsViewModel = hiltViewModel(), navigateBack: () -> Unit, + navigateToFullImage: (String) -> Unit, + navigateToDetails: (Long) -> Unit, + navigateToSource: (MangaSource) -> Unit ) { val context = LocalContext.current - val viewModel: DetailsViewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val chapterListState = rememberLazyListState() + val snackbarHostState = remember { SnackbarHostState() } + var openCategoriesBottomSheet by rememberSaveable { mutableStateOf(false) } + + val uriHandler = LocalUriHandler.current + fun openUrl(url: String) { + uriHandler.openUri(url) + } + + val isChaptersEmpty by viewModel.isChaptersEmpty.collectAsStateWithLifecycle(false) + val chapters by viewModel.chapters.collectAsStateWithLifecycle(emptyList()) + val relatedManga by viewModel.relatedManga.collectAsStateWithLifecycle(emptyList()) + val readingTime by viewModel.readingTime.collectAsStateWithLifecycle(null) + val favouriteCategories by viewModel.favouriteCategories.collectAsStateWithLifecycle() + + LaunchedEffect(mangaId) { + if (viewModel.details.value == null) viewModel.doLoad(mangaId) + } + + LaunchedEffect(Unit) { + viewModel.events.collectLatest { e -> + when (e) { + DetailsViewModel.Event.InternalError -> + snackbarHostState.showSnackbar(context.getString(R.string.error_occured)) + } + } + } Scaffold( topBar = { @@ -59,22 +114,27 @@ fun DetailsView( label = "Top Bar Background", ) DetailsToolbar( - title = uiState.details?.toManga()?.title ?: "Unknown", + title = viewModel.details.value?.toManga()?.title.orEmpty(), titleAlphaProvider = { animatedTitleAlpha }, backgroundAlphaProvider = { animatedBgAlpha }, - onBackClicked = { navigateBack() } + navigateBack = { navigateBack() }, + navigateToWebBrowser = { openUrl(viewModel.details.value?.toManga()?.publicUrl.orEmpty()) }, ) }, - bottomBar = { - - }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState + ) + } ) { contentPadding -> val topPadding = contentPadding.calculateTopPadding() val layoutDirection = LocalLayoutDirection.current + val relatedMangaListState = rememberLazyListState() LazyColumn( modifier = Modifier.fillMaxHeight(), state = chapterListState, contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding() - 60.dp, start = contentPadding.calculateStartPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection), bottom = contentPadding.calculateBottomPadding(), @@ -86,30 +146,49 @@ fun DetailsView( ) { DetailsInfoBox( coil = coil, - imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "", - title = uiState.details?.toManga()?.title ?: "", - author = uiState.details?.toManga()?.author ?: "", + imageUrl = viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty(), + favicon = viewModel.details.value?.toManga()?.source?.faviconUri() ?: Uri.EMPTY, + title = viewModel.details.value?.toManga()?.title.orEmpty(), + altTitle = viewModel.details.value?.toManga()?.altTitle.orEmpty(), + score = viewModel.details.value?.toManga()?.rating ?: RATING_UNKNOWN, + author = viewModel.details.value?.toManga()?.author.orEmpty(), artist = "", - state = uiState.details?.toManga()?.state ?: MangaState.FINISHED, + isNsfw = viewModel.details.value?.toManga()?.isNsfw ?: true, + state = viewModel.details.value?.toManga()?.state ?: MangaState.FINISHED, + source = viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY, + chapters = chapters.size.toString(), isTabletUi = false, appBarPadding = topPadding, + onCoverClick = { + navigateToFullImage( + arrayOf( + viewModel.details.value?.toManga()?.largeCoverUrl.orEmpty() + ).toNavArgument() + ) + }, + isInShelf = favouriteCategories, + onAddToShelfClicked = { + openCategoriesBottomSheet = !openCategoriesBottomSheet + }, + onSourceClicked = { + navigateToSource( + viewModel.details.value?.toManga()?.source ?: MangaSource.DUMMY + ) + } ) } - val time = viewModel.readingTime.value - if (AppSettings.isReadingTimeEstimationEnabled() || time == null) { + /*if (AppSettings.isReadingTimeEstimationEnabled() || readingTime != null) { item { - if (time != null) { PreferenceItem( - title = if (time.isContinue) stringResource(id = R.string.approximate_remaining_time) else stringResource( + title = if (readingTime?.isContinue == true) stringResource(id = R.string.approximate_remaining_time) else stringResource( id = R.string.approximate_reading_time ), - description = time.format(context.resources), + description = readingTime?.format(context.resources), icon = Icons.Outlined.Timelapse ) - } } - } + }*/ item( key = DetailsViewItem.DESCRIPTION_WITH_TAG, @@ -117,13 +196,78 @@ fun DetailsView( ) { ExpandableMangaDescription( defaultExpandState = true, - description = uiState.details?.toManga()?.description ?: "", - tagsProvider = { uiState.details?.toManga()?.tags?.toList() }, + description = viewModel.details.value?.toManga()?.description, + tagsProvider = { viewModel.details.value?.toManga()?.tags }, onTagSearch = { }, onCopyTagToClipboard = { }, ) } + + item { + Column { + Text( + modifier = Modifier.padding(start = 16.dp, end = 8.dp), + text = stringResource(id = R.string.related_manga), + style = MaterialTheme.typography.titleLarge + ) + LazyRow( + modifier = Modifier + .padding(top = 8.dp) + .sizeIn(minHeight = 100.dp), + state = relatedMangaListState, + contentPadding = PaddingValues(horizontal = 8.dp), + flingBehavior = rememberSnapFlingBehavior(lazyListState = relatedMangaListState) + ) { + items( + items = relatedManga, + key = { it.id }, + contentType = { it } + ) { + MangaHorizontalItem( + coil = coil, + manga = it, + onClick = { navigateToDetails(it.id) }, + onLongClick = { }) + } + } + HorizontalDivider(modifier = Modifier.padding(16.dp)) + } + } + + item { + Text( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + text = stringResource(id = R.string.chapters), + style = MaterialTheme.typography.titleLarge + ) + } + + items( + items = chapters + ) { + ChapterListItem( + title = it.chapter.name, + date = it.chapter.uploadDate, + scanlator = it.chapter.scanlator, + read = it.isUnread, + bookmark = false, + selected = false, + onLongClick = { /*TODO*/ }, + onClick = { /*TODO*/ } + ) + } } } + if (openCategoriesBottomSheet) { + val windowInsets = WindowInsets(0) + + ModalBottomSheet( + onDismissRequest = { openCategoriesBottomSheet = false }, + windowInsets = windowInsets + ) { + Text(text = "Hello MBS") + Spacer(modifier = Modifier.height(1000.dp)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt index 9156e55..409337c 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsViewModel.kt @@ -4,61 +4,153 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import org.koitharu.kotatsu.parsers.model.Manga -import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.core.model.findById +import org.xtimms.tokusho.core.model.getPreferredBranch import org.xtimms.tokusho.core.parser.MangaIntent +import org.xtimms.tokusho.data.repository.BookmarksRepository +import org.xtimms.tokusho.data.repository.FavouritesRepository import org.xtimms.tokusho.data.repository.HistoryRepository import org.xtimms.tokusho.sections.details.data.MangaDetails +import org.xtimms.tokusho.sections.details.domain.BranchComparator +import org.xtimms.tokusho.sections.details.domain.DetailsInteractor import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase +import org.xtimms.tokusho.sections.details.domain.RelatedMangaUseCase +import org.xtimms.tokusho.sections.details.model.ChapterItem +import org.xtimms.tokusho.sections.details.model.HistoryInfo +import org.xtimms.tokusho.sections.details.model.MangaBranch import org.xtimms.tokusho.utils.lang.onEachWhile +import org.xtimms.tokusho.utils.lang.removeFirstAndLast import javax.inject.Inject @HiltViewModel class DetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val interactor: DetailsInteractor, private val historyRepository: HistoryRepository, + private val bookmarksRepository: BookmarksRepository, + private val favouritesRepository: FavouritesRepository, private val detailsLoadUseCase: DetailsLoadUseCase, private val readingTimeUseCase: ReadingTimeUseCase, -) : BaseViewModel(), DetailsEvent { + private val relatedMangaUseCase: RelatedMangaUseCase, +) : KotatsuBaseViewModel() { - override val mutableUiState = MutableStateFlow(DetailsUiState()) + private val _events: Channel = Channel(Channel.UNLIMITED) + val events: Flow = _events.receiveAsFlow() + private var loadingJob: Job + private val mangaId = savedStateHandle.get(MANGA_ID_ARGUMENT.removeFirstAndLast())!! private val intent = MangaIntent(savedStateHandle) - private val mangaId = intent.id - private var loadingJob: Job + var details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) - val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) }) - val mangaD = details.map { x -> x?.toManga() } + val manga = details.map { x -> x?.toManga() } .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val history = historyRepository.observeOne(mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + val favouriteCategories = interactor.observeIsFavourite(mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + val remoteManga = MutableStateFlow(null) + @OptIn(ExperimentalCoroutinesApi::class) + val newChaptersCount = details.flatMapLatest { d -> + flowOf(0) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) + private val chaptersQuery = MutableStateFlow("") val selectedBranch = MutableStateFlow(null) - @Deprecated("") - val description = details - .map { it?.description } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null) + val historyInfo: StateFlow = combine( + manga, + selectedBranch, + history, + ) { m, b, h -> + HistoryInfo(m, b, h) + }.stateIn( + scope = viewModelScope + Dispatchers.Default, + started = SharingStarted.Eagerly, + initialValue = HistoryInfo(null, null, null), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val bookmarks = manga.flatMapLatest { + if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val relatedManga: StateFlow> = manga.mapLatest { + if (it != null) { + relatedMangaUseCase.invoke(it).orEmpty() + } else { + emptyList() + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val branches: StateFlow> = combine( + details, + selectedBranch, + history, + ) { m, b, h -> + val c = m?.chapters + if (c.isNullOrEmpty()) { + return@combine emptyList() + } + val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch + c.map { x -> + MangaBranch( + name = x.key, + count = x.value.size, + isSelected = x.key == b, + isCurrent = h != null && x.key == currentBranch, + ) + }.sortedWith(BranchComparator()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) val isChaptersEmpty: StateFlow = details.map { it != null && it.isLoaded && it.allChapters.isEmpty() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + val chapters = combine( + combine( + details, + history, + selectedBranch, + newChaptersCount, + bookmarks, + ) { manga, history, branch, news, bookmarks -> + manga?.mapChapters( + history, + news, + branch, + bookmarks, + ).orEmpty() + }, + chaptersQuery, + ) { list, query -> + list.filterSearch(query) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val readingTime = combine( details, selectedBranch, @@ -67,29 +159,41 @@ class DetailsViewModel @Inject constructor( readingTimeUseCase.invoke(m, b, h) }.stateIn(viewModelScope, SharingStarted.Lazily, null) - init { - loadingJob = doLoad() - } + val selectedBranchValue: String? + get() = selectedBranch.value - fun reload() { - loadingJob.cancel() - loadingJob = doLoad() + init { + loadingJob = doLoad(mangaId) } - private fun doLoad() = launchLoadingJob(Dispatchers.Default) { - detailsLoadUseCase.invoke(mangaId ?: 0L) + fun doLoad(mangaId: Long) = launchLoadingJob(Dispatchers.Default) { + detailsLoadUseCase.invoke(mangaId) .onEachWhile { if (it.allChapters.isEmpty()) { return@onEachWhile false } + val manga = it.toManga() + // find default branch + val hist = historyRepository.getOne(manga) + selectedBranch.value = manga.getPreferredBranch(hist) true + }.catch { error -> + _events.send(Event.InternalError) }.collect { - //details.value = it - mutableUiState.update { - it.copy( - details = details.value - ) - } + details.value = it } } + + private fun List.filterSearch(query: String): List { + if (query.isEmpty() || this.isEmpty()) { + return this + } + return filter { + it.chapter.name.contains(query, ignoreCase = true) + } + } + + sealed interface Event { + data object InternalError : Event + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt index 0d39b3a..ecb5bb5 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt @@ -29,8 +29,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.ImageLoader import coil.compose.AsyncImage import kotlinx.coroutines.launch +import org.xtimms.tokusho.core.AsyncImageImpl import org.xtimms.tokusho.core.components.BackIconButton import org.xtimms.tokusho.core.components.ViewInBrowserButton import org.xtimms.tokusho.ui.theme.TokushoTheme @@ -41,6 +43,7 @@ const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT" @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun FullImageView( + coil: ImageLoader, pictures: Array, navigateBack: () -> Unit, ) { @@ -86,7 +89,8 @@ fun FullImageView( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically ) { - AsyncImage( + AsyncImageImpl( + coil = coil, model = pictures[page], contentDescription = "image$page", modifier = Modifier.fillMaxSize(), @@ -101,21 +105,23 @@ fun FullImageView( .fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - pictures.forEachIndexed { index, _ -> - val color = - if (pagerState.currentPage == index) - MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.primaryContainer - Box( - modifier = Modifier - .padding(4.dp) - .clip(CircleShape) - .background(color) - .size(8.dp) - .clickable { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - } - ) + if (pictures.size > 1) { + pictures.forEachIndexed { index, _ -> + val color = + if (pagerState.currentPage == index) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primaryContainer + Box( + modifier = Modifier + .padding(4.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + .clickable { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + } + ) + } } } } @@ -127,6 +133,7 @@ fun FullImageView( fun FullPosterPreview() { TokushoTheme { FullImageView( + coil = ImageLoader(LocalContext.current), pictures = arrayOf("", ""), navigateBack = {} ) diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt index 8739961..839498a 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/data/MangaDetails.kt @@ -2,9 +2,12 @@ package org.xtimms.tokusho.sections.details.data import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.xtimms.tokusho.core.model.LocalManga +import org.xtimms.tokusho.core.model.isLocal data class MangaDetails( private val manga: Manga, + private val localManga: LocalManga?, val description: CharSequence?, val isLoaded: Boolean, ) { @@ -19,13 +22,44 @@ data class MangaDetails( val allChapters: List by lazy { listOf() } + val isLocal + get() = manga.isLocal + + val local: LocalManga? + get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null + fun toManga() = manga fun filterChapters(branch: String?) = MangaDetails( manga = manga.filterChapters(branch), + localManga = localManga?.run { + copy(manga = manga.filterChapters(branch)) + }, description = description, isLoaded = isLoaded, ) + + private fun mergeChapters(): List { + val chapters = manga.chapters + val localChapters = local?.manga?.chapters.orEmpty() + if (chapters.isNullOrEmpty()) { + return localChapters + } + val localMap = if (localChapters.isNotEmpty()) { + localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id } + } else { + null + } + val result = ArrayList(chapters.size) + for (chapter in chapters) { + val local = localMap?.remove(chapter.id) + result += local ?: chapter + } + if (!localMap.isNullOrEmpty()) { + result.addAll(localMap.values) + } + return result + } } fun Manga.filterChapters(branch: String?): Manga { diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt new file mode 100644 index 0000000..af79ade --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/BranchComparator.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.sections.details.domain + +import org.xtimms.tokusho.sections.details.model.MangaBranch + +class BranchComparator : Comparator { + + override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt new file mode 100644 index 0000000..516d137 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsInteractor.kt @@ -0,0 +1,25 @@ +package org.xtimms.tokusho.sections.details.domain + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.data.repository.FavouritesRepository +import org.xtimms.tokusho.data.repository.HistoryRepository +import javax.inject.Inject + +class DetailsInteractor @Inject constructor( + private val historyRepository: HistoryRepository, + private val favouritesRepository: FavouritesRepository, +) { + + fun observeIsFavourite(mangaId: Long): Flow { + return favouritesRepository.observeCategoriesIds(mangaId) + .map { it.isNotEmpty() } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt index d9f8881..b90e0aa 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/DetailsLoadUseCase.kt @@ -7,14 +7,19 @@ import android.text.style.ForegroundColorSpan import androidx.core.text.getSpans import androidx.core.text.parseAsHtml import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.core.model.isLocal import org.xtimms.tokusho.core.parser.MangaDataRepository +import org.xtimms.tokusho.core.parser.MangaIntent import org.xtimms.tokusho.core.parser.MangaRepository +import org.xtimms.tokusho.core.parser.local.LocalMangaRepository import org.xtimms.tokusho.sections.details.data.MangaDetails +import org.xtimms.tokusho.utils.lang.peek import org.xtimms.tokusho.utils.lang.sanitize import java.io.IOException import javax.inject.Inject @@ -22,6 +27,7 @@ import javax.inject.Inject class DetailsLoadUseCase @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaDataRepository: MangaDataRepository, + private val localMangaRepository: LocalMangaRepository, private val imageGetter: Html.ImageGetter, ) { @@ -29,11 +35,18 @@ class DetailsLoadUseCase @Inject constructor( val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) { "Cannot resolve id $mangaId" } - send(MangaDetails(manga, null, false)) + val local = if (!manga.isLocal) { + async { + localMangaRepository.findSavedManga(manga) + } + } else { + null + } + send(MangaDetails(manga, null, null, false)) try { val details = getDetails(manga) - send(MangaDetails(details, details.description?.parseAsHtml(withImages = false), false)) - send(MangaDetails(details, details.description?.parseAsHtml(withImages = true), true)) + send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false)) + send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true)) } catch (e: IOException) { throw e } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt new file mode 100644 index 0000000..a71066e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/domain/RelatedMangaUseCase.kt @@ -0,0 +1,17 @@ +package org.xtimms.tokusho.sections.details.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.core.parser.MangaRepository +import javax.inject.Inject + +class RelatedMangaUseCase @Inject constructor( + private val mangaRepositoryFactory: MangaRepository.Factory, +) { + + suspend operator fun invoke(seed: Manga) = runCatchingCancellable { + mangaRepositoryFactory.create(seed.source).getRelated(seed) + }.onFailure { + it.printStackTrace() + }.getOrNull() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt new file mode 100644 index 0000000..305806c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ChapterItem.kt @@ -0,0 +1,94 @@ +package org.xtimms.tokusho.sections.details.model + +import android.text.format.DateUtils +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.xtimms.tokusho.core.model.ListModel +import org.xtimms.tokusho.core.model.formatNumber +import org.jsoup.internal.StringUtil.StringJoiner + +data class ChapterItem( + val chapter: MangaChapter, + val flags: Int, + private val uploadDateMs: Long, +) : ListModel { + + var description: String? = null + private set + get() { + if (field != null) return field + field = buildDescription() + return field + } + + var uploadDate: CharSequence? = null + private set + get() { + if (field != null) return field + if (uploadDateMs == 0L) return null + field = DateUtils.getRelativeTimeSpanString( + uploadDateMs, + System.currentTimeMillis(), + DateUtils.DAY_IN_MILLIS, + ) + return field + } + + val isCurrent: Boolean + get() = hasFlag(FLAG_CURRENT) + + val isUnread: Boolean + get() = hasFlag(FLAG_UNREAD) + + val isDownloaded: Boolean + get() = hasFlag(FLAG_DOWNLOADED) + + val isBookmarked: Boolean + get() = hasFlag(FLAG_BOOKMARKED) + + val isNew: Boolean + get() = hasFlag(FLAG_NEW) + + private fun buildDescription(): String { + val joiner = StringJoiner(" • ") + chapter.formatNumber()?.let { + joiner.add("#").append(it) + } + uploadDate?.let { date -> + joiner.add(date.toString()) + } + chapter.scanlator?.let { scanlator -> + if (scanlator.isNotBlank()) { + joiner.add(scanlator) + } + } + return joiner.complete() + } + + private fun hasFlag(flag: Int): Boolean { + return (flags and flag) == flag + } + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is ChapterItem && chapter.id == other.chapter.id + } + + override fun getChangePayload(previousState: ListModel): Any? { + if (previousState !is ChapterItem) { + return super.getChangePayload(previousState) + } + return if (chapter == previousState.chapter && flags != previousState.flags) { + flags + } else { + super.getChangePayload(previousState) + } + } + + companion object { + + const val FLAG_UNREAD = 2 + const val FLAG_CURRENT = 4 + const val FLAG_NEW = 8 + const val FLAG_BOOKMARKED = 16 + const val FLAG_DOWNLOADED = 32 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt new file mode 100644 index 0000000..73dc6de --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/HistoryInfo.kt @@ -0,0 +1,30 @@ +package org.xtimms.tokusho.sections.details.model + +import org.koitharu.kotatsu.parsers.model.Manga +import org.xtimms.tokusho.core.model.MangaHistory + +data class HistoryInfo( + val totalChapters: Int, + val currentChapter: Int, + val history: MangaHistory?, +) { + val isValid: Boolean + get() = totalChapters >= 0 +} + +fun HistoryInfo( + manga: Manga?, + branch: String?, + history: MangaHistory?, +): HistoryInfo { + val chapters = manga?.getChapters(branch) + return HistoryInfo( + totalChapters = chapters?.size ?: -1, + currentChapter = if (history != null && !chapters.isNullOrEmpty()) { + chapters.indexOfFirst { it.id == history.chapterId } + } else { + -1 + }, + history = history, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt new file mode 100644 index 0000000..96e81a1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/ListModelConversionExt.kt @@ -0,0 +1,28 @@ +package org.xtimms.tokusho.sections.details.model + +import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_BOOKMARKED +import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_CURRENT +import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_DOWNLOADED +import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_NEW +import org.xtimms.tokusho.sections.details.model.ChapterItem.Companion.FLAG_UNREAD + +fun MangaChapter.toListItem( + isCurrent: Boolean, + isUnread: Boolean, + isNew: Boolean, + isDownloaded: Boolean, + isBookmarked: Boolean, +): ChapterItem { + var flags = 0 + if (isCurrent) flags = flags or FLAG_CURRENT + if (isUnread) flags = flags or FLAG_UNREAD + if (isNew) flags = flags or FLAG_NEW + if (isBookmarked) flags = flags or FLAG_BOOKMARKED + if (isDownloaded) flags = flags or FLAG_DOWNLOADED + return ChapterItem( + chapter = this, + flags = flags, + uploadDateMs = uploadDate, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt new file mode 100644 index 0000000..c1916ba --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/model/MangaBranch.kt @@ -0,0 +1,19 @@ +package org.xtimms.tokusho.sections.details.model + +import org.xtimms.tokusho.core.model.ListModel + +data class MangaBranch( + val name: String?, + val count: Int, + val isSelected: Boolean, + val isCurrent: Boolean, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is MangaBranch && other.name == name + } + + override fun toString(): String { + return "$name: $count" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt index a83e42b..ca30fc7 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt @@ -2,76 +2,68 @@ package org.xtimms.tokusho.sections.explore import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.ExtensionOff import androidx.compose.material.icons.outlined.SdStorage +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.ImageLoader -import org.koitharu.kotatsu.parsers.model.MangaSource import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.ExploreButton +import org.xtimms.tokusho.core.components.PreferencesHintCard import org.xtimms.tokusho.core.components.SourceItem import org.xtimms.tokusho.core.components.icons.Dice -import org.xtimms.tokusho.core.parser.favicon.faviconUri -import org.xtimms.tokusho.utils.system.toast +import org.xtimms.tokusho.ui.theme.TokushoTheme const val EXPLORE_DESTINATION = "explore" @Composable fun ExploreView( + viewModel: ExploreViewModel = hiltViewModel(), coil: ImageLoader, - navigateToSource: (MangaSource) -> Unit, - topBarHeightPx: Float, - listState: LazyGridState, - padding: PaddingValues, -) { - val viewModel: ExploreViewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - ExploreViewContent( - coil = coil, - navigateToSource = navigateToSource, - uiState = uiState, - event = viewModel, - topBarHeightPx = topBarHeightPx, - listState = listState, - padding = padding - ) -} - -@Composable -fun ExploreViewContent( - coil: ImageLoader, - navigateToSource: (MangaSource) -> Unit, - uiState: ExploreUiState, - event: ExploreEvent?, + navigateToSource: (SourceItemModel) -> Unit, nestedScrollConnection: NestedScrollConnection? = null, topBarHeightPx: Float = 0f, topBarOffsetY: Animatable = Animatable(0f), @@ -82,12 +74,7 @@ fun ExploreViewContent( val context = LocalContext.current val layoutDirection = LocalLayoutDirection.current - if (uiState.message != null) { - LaunchedEffect(uiState.message) { - context.toast(uiState.message) - event?.onMessageDisplayed() - } - } + val sources = viewModel.content.collectAsStateWithLifecycle(emptyList()) Box( modifier = Modifier @@ -104,7 +91,7 @@ fun ExploreViewContent( else Modifier ) LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 90.dp), + columns = GridCells.Fixed(4), modifier = listModifier, state = listState, contentPadding = PaddingValues( @@ -154,9 +141,71 @@ fun ExploreViewContent( ) } } + item( + span = { GridItemSpan(maxCurrentLineSpan) } + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(MaterialTheme.shapes.extraLarge), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + ) + ) { + Column( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = "Рекомендации", + style = MaterialTheme.typography.labelLarge + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(72.dp)) + .aspectRatio(1f), + contentScale = ContentScale.Crop, + painter = painterResource(id = R.drawable.ookami), + contentDescription = "" + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = "Text", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Text", + modifier = Modifier.padding(vertical = 2.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Button( + onClick = {}, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "More") + } + } + } + } items( - items = uiState.sources, - key = { it.name }, + items = sources.value, + key = { it.id }, contentType = { it } ) { item -> Box( @@ -165,7 +214,7 @@ fun ExploreViewContent( ) { SourceItem( coil = coil, - faviconUrl = item.faviconUri(), + faviconUrl = item.favicon, title = item.title ) { navigateToSource(item) @@ -174,4 +223,23 @@ fun ExploreViewContent( } } } +} + +@PreviewLightDark +@Composable +fun RecommendationPreview() { + TokushoTheme { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.manga_sources), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelLarge + ) + TextButton(onClick = { /*TODO*/ }) { + Text(text = stringResource(id = R.string.catalog)) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt index 0c10a17..429bec5 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt @@ -3,34 +3,51 @@ package org.xtimms.tokusho.sections.explore import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.core.parser.favicon.faviconUri +import org.xtimms.tokusho.data.repository.ExploreRepository import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import org.xtimms.tokusho.utils.lang.mapItems import javax.inject.Inject @HiltViewModel class ExploreViewModel @Inject constructor( + private val exploreRepository: ExploreRepository, private val mangaSourcesRepository: MangaSourcesRepository, -) : BaseViewModel(), ExploreEvent { +) : KotatsuBaseViewModel() { - override val mutableUiState = MutableStateFlow( - ExploreUiState( - isLoading = true, - ) - ) + private val sourcesStateFlow = mangaSourcesRepository.observeEnabledSources() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - init { - launchJob(Dispatchers.Default) { - val result = mangaSourcesRepository.allMangaSources - mutableUiState.update { - it.copy( - sources = result.toList(), - ) - } - setLoading(false) - } + val content = sourcesStateFlow + .filterNotNull() + .mapItems { SourceItemModel(it.ordinal, it.name, it.title, it.faviconUri()) } + .distinctUntilChanged() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + private fun createContentFlow() = combine( + mangaSourcesRepository.observeEnabledSources(), + mangaSourcesRepository.observeNewSources(), + ) { content, newSources -> + buildList(content, newSources) + } + + private fun buildList( + sources: List, + newSources: Set, + ): List { + val result = ArrayList(sources.size + 3) + return result } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt new file mode 100644 index 0000000..3bec067 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/SourceItemModel.kt @@ -0,0 +1,16 @@ +package org.xtimms.tokusho.sections.explore + +import android.net.Uri +import org.xtimms.tokusho.core.model.ListModel + +data class SourceItemModel( + val id: Int, + val name: String, + val title: String, + val favicon: Uri +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourceItemModel && other.id == id + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt new file mode 100644 index 0000000..1daf28e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/data/SourcesSortOrder.kt @@ -0,0 +1,12 @@ +package org.xtimms.tokusho.sections.explore.data + +import androidx.annotation.StringRes +import org.xtimms.tokusho.R + +enum class SourcesSortOrder( + @StringRes val titleResId: Int, +) { + ALPHABETIC(R.string.by_name), + POPULARITY(R.string.popular), + MANUAL(R.string.manual), +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt new file mode 100644 index 0000000..8c692ee --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/feed/FeedView.kt @@ -0,0 +1,52 @@ +package org.xtimms.tokusho.sections.feed + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ClearAll +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar + +const val FEED_DESTINATION = "feed" + +@Composable +fun FeedView( + navigateBack: () -> Unit, + navigateToShelf: () -> Unit, +) { + rememberScrollState() + + ScaffoldWithClassicTopAppBar( + title = stringResource(R.string.feed), + navigateBack = navigateBack, + actions = { + IconButton(onClick = { navigateToShelf() }) { + Icon(imageVector = Icons.Outlined.Tune, contentDescription = null) + } + }, + floatingActionButton = { + ExtendedFloatingActionButton(onClick = { /*TODO*/ } + ) { + Icon( + imageVector = Icons.Outlined.ClearAll, + contentDescription = "Clear all" + ) + Text( + text = stringResource(R.string.clear_all), + modifier = Modifier.padding(start = 16.dp, end = 8.dp) + ) + } + } + ) { padding -> + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt index c164119..c8ce003 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.History import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -47,7 +49,11 @@ fun HistoryViewContent( ) .padding(padding) ) { - EmptyScreen(title = R.string.nothing_here) + EmptyScreen( + icon = Icons.Outlined.History, + title = R.string.empty_history_title, + description = R.string.empty_history_description + ) } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt index 6216eb8..00a827d 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt @@ -1,14 +1,18 @@ package org.xtimms.tokusho.sections.list +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only @@ -30,7 +34,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.ImageLoader import org.koitharu.kotatsu.parsers.model.MangaSource -import org.xtimms.tokusho.core.components.MangaCompactGridItem +import org.xtimms.tokusho.core.components.MangaGridItem import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBarWithChips import org.xtimms.tokusho.utils.composable.onBottomReached import org.xtimms.tokusho.utils.system.toast @@ -78,7 +82,16 @@ private fun MangaListViewContent( ScaffoldWithSmallTopAppBarWithChips( title = source.title, - chips = listOf("Chip 1", "Chip 2", "Chip 3", "Chip 4", "Chip 1", "Chip 2", "Chip 3", "Chip 4"), + chips = listOf( + "Chip 1", + "Chip 2", + "Chip 3", + "Chip 4", + "Chip 1", + "Chip 2", + "Chip 3", + "Chip 4" + ), navigateBack = navigateBack, contentWindowInsets = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) @@ -87,45 +100,59 @@ private fun MangaListViewContent( listState.onBottomReached(buffer = 5) { event?.loadMore() } - Column( + Box( modifier = Modifier .padding(padding), - horizontalAlignment = Alignment.CenterHorizontally + contentAlignment = Alignment.Center ) { - if (!uiState.isLoading) LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 100.dp), - state = listState, - modifier = Modifier.fillMaxHeight(), - contentPadding = PaddingValues( - start = 8.dp, - top = 8.dp, - end = 8.dp, - bottom = WindowInsets.navigationBars.asPaddingValues() - .calculateBottomPadding() - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + AnimatedVisibility( + visible = uiState.isLoading, + exit = fadeOut(), ) { - items( - items = uiState.manga, - key = { it.id }, - contentType = { it } - ) { item -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.TopCenter - ) { - MangaCompactGridItem( - coil = coil, - imageUrl = item.coverUrl, - title = item.title, - onClick = { navigateToDetails(item.id) }, - onLongClick = { }, - ) + CircularProgressIndicator() + } + AnimatedVisibility( + visible = !uiState.isLoading, + enter = slideInVertically(tween(500)) { 64 } + fadeIn(), + exit = fadeOut() + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 100.dp), + state = listState, + modifier = Modifier.fillMaxHeight(), + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally + ), + ) { + items( + items = uiState.manga, + key = { it.id }, + contentType = { it } + ) { item -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + MangaGridItem( + coil = coil, + manga = item, + onClick = { + navigateToDetails(item.id) + }, + onLongClick = { }, + ) + } } } - } else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() } } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt index be7269b..7b439b5 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.SearchOff import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -102,7 +104,11 @@ fun SearchView( ) { val context = LocalContext.current - EmptyScreen(title = R.string.nothing_here) + EmptyScreen( + icon = Icons.Outlined.SearchOff, + title = R.string.nothing_found, + description = R.string.nothing_found_summary + ) } @Preview(showBackground = true) diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt index b045bb4..4237d22 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt @@ -22,12 +22,15 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BatterySaver import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.LocalLibrary import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.Wifi import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,60 +44,30 @@ import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.PreferencesHintCard import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar import org.xtimms.tokusho.core.components.SettingItem -import org.xtimms.tokusho.sections.settings.storage.StorageEvent -import org.xtimms.tokusho.sections.settings.storage.StorageUiState -import org.xtimms.tokusho.sections.settings.storage.StorageViewModel import org.xtimms.tokusho.utils.FileSize -import org.xtimms.tokusho.utils.system.toast const val SETTINGS_DESTINATION = "settings" -@Composable -fun SettingsView( - navigateBack: () -> Unit, - navigateToAppearance: () -> Unit, - navigateToAbout: () -> Unit, - navigateToAdvanced: () -> Unit, - navigateToShelfSettings: () -> Unit, - navigateToStorage: () -> Unit -) { - - val viewModel: StorageViewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - SettingsViewContent( - uiState = uiState, - event = viewModel, - navigateBack = navigateBack, - navigateToAppearance = navigateToAppearance, - navigateToAbout = navigateToAbout, - navigateToAdvanced = navigateToAdvanced, - navigateToShelfSettings = navigateToShelfSettings, - navigateToStorage = navigateToStorage - ) -} - @SuppressLint("BatteryLife") @Composable -private fun SettingsViewContent( - uiState: StorageUiState, - event: StorageEvent?, +fun SettingsView( + viewModel: SettingsViewModel = hiltViewModel(), navigateBack: () -> Unit, navigateToAppearance: () -> Unit, navigateToAbout: () -> Unit, navigateToAdvanced: () -> Unit, + navigateToBackupRestoreSettings: () -> Unit, + navigateToMangaSources: () -> Unit, + navigateToNetwork: () -> Unit, navigateToShelfSettings: () -> Unit, navigateToStorage: () -> Unit ) { val context = LocalContext.current - if (uiState.message != null) { - LaunchedEffect(uiState.message) { - context.toast(uiState.message) - event?.onMessageDisplayed() - } - } + val state by viewModel.viewStateFlow.collectAsState() + val total = viewModel.totalSourcesCount + val enabled = viewModel.enabledSourcesCount.collectAsStateWithLifecycle() val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager var showBatteryHint by remember { @@ -155,6 +128,18 @@ private fun SettingsViewContent( onClick = navigateToAppearance ) } + item { + SettingItem( + title = stringResource(id = R.string.manga_sources), + description = if (enabled.value >= 0) stringResource( + id = R.string.enabled_d_of_d, + enabled.value, + total + ) else context.resources.getQuantityString(R.plurals.items, total, total), + icon = Icons.Outlined.CollectionsBookmark, + onClick = navigateToMangaSources + ) + } item { SettingItem( title = stringResource(id = R.string.nav_shelf), @@ -164,26 +149,42 @@ private fun SettingsViewContent( ) } item { - val allCaches = uiState.httpCacheSize + - uiState.pagesCache + - uiState.thumbnailsCache + SettingItem( + title = stringResource(id = R.string.backup_and_restore), + description = "TODO", + icon = Icons.Outlined.SettingsBackupRestore, + onClick = navigateToBackupRestoreSettings + ) + } + item { + SettingItem( + title = stringResource(id = R.string.network), + description = stringResource(id = R.string.network_page), + icon = Icons.Outlined.Wifi, + onClick = navigateToNetwork + ) + } + item { + val allCaches = state.httpCacheSize + + state.pagesCache + + state.thumbnailsCache val desc = buildString { - append((allCaches / uiState.availableSpace) * 100) + append((allCaches / state.availableSpace) * 100) append(context.getString(R.string.space_used)) append(" - ") append( FileSize.BYTES.freeFormat( context, - (uiState.availableSpace - - uiState.httpCacheSize - - uiState.pagesCache - - uiState.thumbnailsCache).toFloat() + (state.availableSpace - + state.httpCacheSize - + state.pagesCache - + state.thumbnailsCache).toFloat() ) ) } SettingItem( title = stringResource(id = R.string.storage), - description = if (uiState.isLoading) context.getString(R.string.calculating_) else desc, + description = desc, icon = Icons.Outlined.Storage, onClick = navigateToStorage ) diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt new file mode 100644 index 0000000..8f6d7c1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsViewModel.kt @@ -0,0 +1,58 @@ +package org.xtimms.tokusho.sections.settings + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import kotlinx.coroutines.runInterruptible +import okhttp3.Cache +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.core.cache.CacheDir +import org.xtimms.tokusho.data.LocalStorageManager +import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import org.xtimms.tokusho.sections.settings.sources.SourcesSettingsViewModel +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val storageManager: LocalStorageManager, + private val httpCache: Cache, + sourcesRepository: MangaSourcesRepository, +) : KotatsuBaseViewModel() { + + private var storageUsageJob: Job? = null + + private val mutableViewStateFlow = MutableStateFlow(ViewState()) + val viewStateFlow = mutableViewStateFlow.asStateFlow() + + data class ViewState( + val pagesCache: Long = -1L, + val thumbnailsCache: Long = -1L, + val availableSpace: Long = -1L, + val httpCacheSize: Long = -1L, + ) + + init { + storageUsageJob = launchJob(Dispatchers.Default) { + mutableViewStateFlow.update { + it.copy( + availableSpace = storageManager.computeAvailableSize(), + pagesCache = storageManager.computeCacheSize(CacheDir.PAGES), + thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS), + httpCacheSize = runInterruptible { httpCache.size() } + ) + } + } + } + + val totalSourcesCount = sourcesRepository.allMangaSources.size + + val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt index 58547d9..4d536ca 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.DeveloperBoard import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.UpdateDisabled @@ -40,6 +41,7 @@ const val weblate = "https://hosted.weblate.org/engage/tokusho/" @Composable fun AboutView( navigateBack: () -> Unit, + navigateToLicensesPage: () -> Unit, navigateToUpdatePage: () -> Unit, ) { @@ -95,6 +97,14 @@ fun AboutView( context.toast(R.string.info_copied) } } + item { + PreferenceItem( + title = stringResource(id = R.string.open_source_licenses), + icon = Icons.Outlined.DeveloperBoard + ) { + navigateToLicensesPage() + } + } } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt new file mode 100644 index 0000000..32a8536 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/LicenseView.kt @@ -0,0 +1,66 @@ +package org.xtimms.tokusho.sections.settings.about + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat +import com.google.android.material.textview.MaterialTextView +import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar + +const val LICENSE_NAME_ARGUMENT = "{name}" +const val LICENSE_WEBSITE_ARGUMENT = "{website}" +const val LICENSE_CONTENT_ARGUMENT = "{content}" +const val LICENSE_DESTINATION = + "license/${LICENSE_NAME_ARGUMENT}?${LICENSE_WEBSITE_ARGUMENT}?${LICENSE_CONTENT_ARGUMENT}" + +@Composable +fun LicenseView( + name: String, + website: String, + license: String, + navigateBack: () -> Unit +) { + + val uriHandler = LocalUriHandler.current + + ScaffoldWithClassicTopAppBar( + title = name, + navigateBack = navigateBack, + actions = { + IconButton(onClick = { uriHandler.openUri(website) }) { + Icon(imageVector = Icons.Outlined.Public, contentDescription = null) + } + } + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(16.dp), + ) { + HtmlLicenseText(html = license) + } + } +} + +@Composable +private fun HtmlLicenseText(html: String) { + AndroidView( + factory = { + MaterialTextView(it) + }, + update = { + it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt new file mode 100644 index 0000000..74e4d49 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/OpenSourceLicensesView.kt @@ -0,0 +1,37 @@ +package org.xtimms.tokusho.sections.settings.about + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.m3.util.htmlReadyLicenseContent +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar + +const val LICENSES_DESTINATION = "licenses" + +@Composable +fun OpenSourceLicensesView( + navigateBack: () -> Unit, + navigateToLicensePage: (String, String?, String?) -> Unit +) { + + ScaffoldWithClassicTopAppBar( + title = stringResource(R.string.about), + navigateBack = navigateBack + ) { padding -> + LibrariesContainer( + modifier = Modifier + .fillMaxSize(), + contentPadding = padding, + onLibraryClick = { + navigateToLicensePage( + it.library.name, + it.library.website, + it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty() + ) + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt index 7127008..bdaa30c 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.outlined.ColorLens import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Numbers import androidx.compose.material.icons.outlined.Timelapse import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -65,6 +66,7 @@ import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON import org.xtimms.tokusho.core.prefs.READING_TIME import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT +import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT import org.xtimms.tokusho.core.prefs.paletteStyles import org.xtimms.tokusho.ui.harmonize.hct.Hct import org.xtimms.tokusho.ui.monet.LocalTonalPalettes @@ -83,7 +85,6 @@ val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40 @OptIn(ExperimentalFoundationApi::class) @Composable fun AppearanceView( - coil: ImageLoader, navigateBack: () -> Unit, navigateToDarkTheme: () -> Unit, navigateToLanguages: () -> Unit diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt new file mode 100644 index 0000000..cbee200 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/AppBackupAgent.kt @@ -0,0 +1,118 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.app.backup.BackupAgent +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.FullBackupDataOutput +import android.content.Context +import android.os.ParcelFileDescriptor +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.runBlocking +import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.data.repository.backup.BackupEntry +import org.xtimms.tokusho.data.repository.backup.BackupRepository +import org.xtimms.tokusho.data.repository.backup.BackupZipInput +import org.xtimms.tokusho.data.repository.backup.BackupZipOutput +import java.io.File +import java.io.FileDescriptor +import java.io.FileInputStream +import java.io.InputStream +import java.io.OutputStream + +class AppBackupAgent : BackupAgent() { + + override fun onBackup( + oldState: ParcelFileDescriptor?, + data: BackupDataOutput?, + newState: ParcelFileDescriptor? + ) = Unit + + override fun onRestore( + data: BackupDataInput?, + appVersionCode: Int, + newState: ParcelFileDescriptor? + ) = Unit + + override fun onFullBackup(data: FullBackupDataOutput) { + super.onFullBackup(data) + val file = + createBackupFile(this, BackupRepository(TokushoDatabase(applicationContext))) + try { + fullBackupFile(file, data) + } finally { + file.delete() + } + } + + override fun onRestoreFile( + data: ParcelFileDescriptor, + size: Long, + destination: File?, + type: Int, + mode: Long, + mtime: Long + ) { + if (destination?.name?.endsWith(".bk.zip") == true) { + restoreBackupFile( + data.fileDescriptor, + size, + BackupRepository(TokushoDatabase(applicationContext)), + ) + destination.delete() + } else { + super.onRestoreFile(data, size, destination, type, mode, mtime) + } + } + + @VisibleForTesting + fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking { + BackupZipOutput(context).use { backup -> + backup.put(repository.createIndex()) + backup.put(repository.dumpHistory()) + backup.put(repository.dumpCategories()) + backup.put(repository.dumpFavourites()) + backup.put(repository.dumpBookmarks()) + backup.put(repository.dumpSources()) + backup.finish() + backup.file + } + } + + @VisibleForTesting + fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) { + val tempFile = File.createTempFile("backup_", ".tmp") + FileInputStream(fd).use { input -> + tempFile.outputStream().use { output -> + input.copyLimitedTo(output, size) + } + } + val backup = BackupZipInput(tempFile) + try { + runBlocking { + backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) } + backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } + backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) } + backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) } + backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) } + } + } finally { + backup.close() + tempFile.delete() + } + } + + private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) { + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt())) + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + val bytesLeft = (limit - bytesCopied).toInt() + if (bytesLeft <= 0) { + break + } + bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt new file mode 100644 index 0000000..7592cc3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupEntryModel.kt @@ -0,0 +1,28 @@ +package org.xtimms.tokusho.sections.settings.backup + +import androidx.annotation.StringRes +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.model.ListModel +import org.xtimms.tokusho.data.repository.backup.BackupEntry + +data class BackupEntryModel( + val name: BackupEntry.Name, + val isChecked: Boolean, + val isEnabled: Boolean, +) : ListModel { + + @get:StringRes + val titleResId: Int + get() = when (name) { + BackupEntry.Name.INDEX -> 0 // should not appear here + BackupEntry.Name.HISTORY -> R.string.history + BackupEntry.Name.CATEGORIES -> R.string.categories + BackupEntry.Name.FAVOURITES -> R.string.nav_shelf + BackupEntry.Name.BOOKMARKS -> R.string.bookmarks + BackupEntry.Name.SOURCES -> R.string.remote_sources + } + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is BackupEntryModel && other.name == name + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt new file mode 100644 index 0000000..09d6e28 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupItem.kt @@ -0,0 +1,65 @@ +package org.xtimms.tokusho.sections.settings.backup + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.core.components.PreferenceItemDescription +import org.xtimms.tokusho.core.components.PreferenceItemTitle +import org.xtimms.tokusho.ui.theme.TokushoTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BackupItem( + title: String, + enabled: Boolean = true, + isChecked: Boolean = true, + onClick: () -> Unit = {}, +) { + Surface( + modifier = Modifier.combinedClickable( + onClick = onClick, + enabled = enabled, + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + modifier = Modifier.padding(start = 8.dp), + checked = isChecked, + onCheckedChange = null + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp) + ) { + PreferenceItemTitle(text = title, enabled = enabled) + } + } + } +} + +@Preview +@Composable +fun BackupItemPreview() { + TokushoTheme { + BackupItem(title = "Title") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt new file mode 100644 index 0000000..3457cad --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupObserver.kt @@ -0,0 +1,23 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.app.backup.BackupManager +import android.content.Context +import androidx.room.InvalidationTracker +import dagger.hilt.android.qualifiers.ApplicationContext +import org.xtimms.tokusho.core.database.TABLE_FAVOURITES +import org.xtimms.tokusho.core.database.TABLE_FAVOURITE_CATEGORIES +import org.xtimms.tokusho.core.database.TABLE_HISTORY +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BackupObserver @Inject constructor( + @ApplicationContext context: Context, +) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) { + + private val backupManager = BackupManager(context) + + override fun onInvalidated(tables: Set) { + backupManager.dataChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt new file mode 100644 index 0000000..c77ef9d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt @@ -0,0 +1,218 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AvTimer +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material.icons.outlined.SdCardAlert +import androidx.compose.material.icons.outlined.SnippetFolder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceInfo +import org.xtimms.tokusho.core.components.PreferenceItem +import org.xtimms.tokusho.core.components.PreferenceSubtitle +import org.xtimms.tokusho.core.components.PreferenceSwitchWithContainer +import org.xtimms.tokusho.core.components.PreferencesHintCard +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.components.icons.Kotatsu +import org.xtimms.tokusho.utils.lang.tryLaunch +import org.xtimms.tokusho.utils.system.toast +import java.io.File +import java.io.FileOutputStream + +const val BACKUP_RESTORE_DESTINATION = "backup_restore" + +@Composable +fun BackupRestoreView( + backupViewModel: BackupViewModel = hiltViewModel(), + restoreViewModel: RestoreViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToRestoreScreen: (String) -> Unit +) { + + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var backup: File? = null + + fun saveBackup(file: File, output: Uri) { + try { + context.contentResolver.openFileDescriptor(output, "w")?.use { fd -> + FileOutputStream(fd.fileDescriptor).use { + it.write(file.readBytes()) + } + } + Toast.makeText(context, R.string.backup_saved, Toast.LENGTH_SHORT).show() + } catch (e: InterruptedException) { + throw e + } catch (e: Exception) { + e.printStackTrace() + } + } + + val writeBackup = rememberLauncherForActivityResult( + object : ActivityResultContracts.CreateDocument("application/zip") { + override fun createIntent(context: Context, input: String): Intent { + val intent = super.createIntent(context, input) + return Intent.createChooser(intent, context.getString(R.string.file_create_backup)) + } + } + ) { uri -> + val file = backup + if (uri != null && file != null) { + saveBackup(file, uri) + } else { + return@rememberLauncherForActivityResult + } + } + + val chooseBackup = rememberLauncherForActivityResult( + object : ActivityResultContracts.OpenDocument() { + override fun createIntent(context: Context, input: Array): Intent { + val intent = super.createIntent(context, input) + return Intent.createChooser(intent, context.getString(R.string.file_select_backup)) + } + }, + ) { uri -> + if (uri == null) { + context.toast(R.string.file_null_uri_error) + return@rememberLauncherForActivityResult + } + + navigateToRestoreScreen(uri.toString()) + } + + val showDirectoryAlert = + Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager() + + ScaffoldWithTopAppBar( + title = stringResource(R.string.backup_and_restore), + snackbarHost = { + SnackbarHost( + modifier = Modifier.systemBarsPadding(), + hostState = snackbarHostState + ) + }, + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + ) { + if (showDirectoryAlert) + item { + PreferencesHintCard( + title = stringResource(R.string.permission_issue), + description = stringResource(R.string.permission_issue_desc), + icon = Icons.Outlined.SdCardAlert, + ) { + if (Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager()) { + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = Uri.parse("package:" + context.packageName) + if (resolveActivity(context.packageManager) != null) + context.startActivity(this) + } + } + } + } + item { + PreferencesHintCard( + title = stringResource(R.string.supports_kotatsu_backups), + description = stringResource(R.string.supports_kotatsu_backups_desc), + icon = Icons.Filled.Kotatsu, + ) + } + item { + PreferenceSwitchWithContainer( + title = stringResource(id = R.string.enable_periodic_backups), + icon = null, + isChecked = true + ) { + + } + } + item { PreferenceSubtitle(text = stringResource(id = R.string.general)) } + item { + PreferenceItem( + title = stringResource(id = R.string.backup_creation_frequency), + description = "Once per week", + icon = Icons.Outlined.AvTimer + ) + } + item { + PreferenceItem( + title = stringResource(id = R.string.backup_output_directory), + description = "TODO", + icon = Icons.Outlined.SnippetFolder + ) + } + item { PreferenceSubtitle(text = stringResource(id = R.string.actions)) } + item { + PreferenceItem( + title = stringResource(id = R.string.create_data_backup), + description = stringResource(id = R.string.create_data_backup_desc), + icon = Icons.Outlined.Create, + trailingIcon = { UpdateProgressIndicator() } + ) { + writeBackup.tryLaunch(backup?.name ?: "") + } + } + item { + PreferenceItem( + title = stringResource(id = R.string.restore_from_backup), + description = stringResource(id = R.string.restore_from_backup_desc), + icon = Icons.Outlined.Restore + ) { + chooseBackup.launch(arrayOf("*/*")) + } + } + item { HorizontalDivider() } + item { + PreferenceInfo(text = stringResource(id = R.string.backup_restore_hint)) + } + } + } + +} + +@Composable +private fun UpdateProgressIndicator() { + CircularProgressIndicator( + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp) + .padding(2.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt new file mode 100644 index 0000000..91afbfb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupViewModel.kt @@ -0,0 +1,52 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.content.Context +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.data.repository.backup.BackupRepository +import org.xtimms.tokusho.data.repository.backup.BackupZipOutput +import org.xtimms.tokusho.utils.lang.MutableEventFlow +import org.xtimms.tokusho.utils.lang.call +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class BackupViewModel @Inject constructor( + private val repository: BackupRepository, + @ApplicationContext context: Context, +) : KotatsuBaseViewModel() { + + val progress = MutableStateFlow(-1f) + val onBackupDone = MutableEventFlow() + + init { + launchLoadingJob { + val file = BackupZipOutput(context).use { backup -> + val step = 1f / 6f + backup.put(repository.createIndex()) + + progress.value = 0f + backup.put(repository.dumpHistory()) + + progress.value += step + backup.put(repository.dumpCategories()) + + progress.value += step + backup.put(repository.dumpFavourites()) + + progress.value += step + backup.put(repository.dumpBookmarks()) + + progress.value += step + backup.put(repository.dumpSources()) + + backup.finish() + progress.value = 1f + backup.file + } + onBackupDone.call(file) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt new file mode 100644 index 0000000..3d7b28a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/PeriodicalBackupWorker.kt @@ -0,0 +1,98 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.content.Context +import android.os.Build +import androidx.documentfile.provider.DocumentFile +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import androidx.work.workDataOf +import dagger.Reusable +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.xtimms.tokusho.data.repository.backup.BackupRepository +import org.xtimms.tokusho.data.repository.backup.BackupZipOutput +import org.xtimms.tokusho.utils.lang.awaitUniqueWorkInfoByName +import org.xtimms.tokusho.utils.system.deleteAwait +import org.xtimms.tokusho.work.PeriodicWorkScheduler +import java.util.Date +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltWorker +class PeriodicalBackupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val repository: BackupRepository, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val resultData = workDataOf(DATA_TIMESTAMP to Date().time) + val file = BackupZipOutput(applicationContext).use { backup -> + backup.put(repository.createIndex()) + backup.put(repository.dumpHistory()) + backup.put(repository.dumpCategories()) + backup.put(repository.dumpFavourites()) + backup.put(repository.dumpBookmarks()) + backup.put(repository.dumpSources()) + backup.finish() + backup.file + } + return Result.success(resultData) + } + + @Reusable + class Scheduler @Inject constructor( + private val workManager: WorkManager, + ) : PeriodicWorkScheduler { + + override suspend fun schedule() { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + constraints.setRequiresDeviceIdle(true) + val request = PeriodicWorkRequestBuilder( + 10000, + TimeUnit.DAYS, + ).setConstraints(constraints.build()) + .keepResultsForAtLeast(20, TimeUnit.DAYS) + .addTag(TAG) + .build() + workManager + .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) + .await() + } + + override suspend fun unschedule() { + workManager + .cancelUniqueWork(TAG) + .await() + } + + override suspend fun isScheduled(): Boolean { + return workManager + .awaitUniqueWorkInfoByName(TAG) + .any { !it.state.isFinished } + } + + suspend fun getLastSuccessfulBackup(): Date? { + return workManager + .awaitUniqueWorkInfoByName(TAG) + .lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED } + ?.outputData + ?.getLong(DATA_TIMESTAMP, 0) + ?.let { if (it != 0L) Date(it) else null } + } + } + + private companion object { + + const val TAG = "backups" + const val DATA_TIMESTAMP = "ts" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt new file mode 100644 index 0000000..7470d69 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt @@ -0,0 +1,107 @@ +package org.xtimms.tokusho.sections.settings.backup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material.icons.outlined.Update +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferencesHintCard +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.updates.Updater +import org.xtimms.tokusho.sections.settings.about.ProgressIndicatorButton +import org.xtimms.tokusho.utils.DeviceUtil +import org.xtimms.tokusho.utils.system.suspendToast + +const val RESTORE_ARGUMENT = "{source}" +const val RESTORE_DESTINATION = "restore/?file=${RESTORE_ARGUMENT}" + +@Composable +fun RestoreItemsView( + uri: String, + restoreViewModel: RestoreViewModel = hiltViewModel(), + navigateBack: () -> Unit, +) { + + val items = restoreViewModel.availableEntries.collectAsStateWithLifecycle() + + ScaffoldWithTopAppBar( + title = stringResource(R.string.restore_from_backup), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + ) { + if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { + item { + PreferencesHintCard( + title = stringResource(id = R.string.restore_miui_warning), + icon = null + ) + } + } + item { + PreferencesHintCard( + title = stringResource(id = R.string.backup_creation_date), + description = restoreViewModel.backupDate.value.toString(), + icon = Icons.Outlined.AccessTime + ) + } + items( + count = 5 + ) { + BackupItem( + title = it.toString() + ) + } + item { + var isLoading by remember { mutableStateOf(false) } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + ProgressIndicatorButton( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 6.dp) + .padding(bottom = 12.dp), + text = stringResource( + id = R.string.restore + ), + icon = Icons.Outlined.Restore, + isLoading = isLoading + ) { + restoreViewModel.restore() + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt new file mode 100644 index 0000000..1329497 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt @@ -0,0 +1,149 @@ +package org.xtimms.tokusho.sections.settings.backup + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.parsers.util.SuspendLazy +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.data.repository.backup.BackupEntry +import org.xtimms.tokusho.data.repository.backup.BackupRepository +import org.xtimms.tokusho.data.repository.backup.BackupZipInput +import org.xtimms.tokusho.data.repository.backup.CompositeResult +import org.xtimms.tokusho.utils.lang.MutableEventFlow +import org.xtimms.tokusho.utils.lang.call +import org.xtimms.tokusho.utils.lang.toUriOrNull +import java.io.File +import java.io.FileNotFoundException +import java.util.Date +import java.util.EnumMap +import java.util.EnumSet +import javax.inject.Inject + +@HiltViewModel +class RestoreViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: BackupRepository, + @ApplicationContext context: Context, +) : KotatsuBaseViewModel() { + + private val backupInput = SuspendLazy { + val uri = savedStateHandle.get(RESTORE_ARGUMENT)?.toUriOrNull() ?: throw FileNotFoundException() + val contentResolver = context.contentResolver + runInterruptible(Dispatchers.IO) { + val tempFile = File.createTempFile("backup_", ".tmp") + (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + BackupZipInput(tempFile) + } + } + + val progress = MutableStateFlow(-1f) + val onRestoreDone = MutableEventFlow() + + val availableEntries = MutableStateFlow>(emptyList()) + val backupDate = MutableStateFlow(null) + + init { + launchLoadingJob(Dispatchers.Default) { + val backup = backupInput.get() + val entries = backup.entries() + availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> + if (entry == BackupEntry.Name.INDEX || entry !in entries) { + return@mapNotNull null + } + BackupEntryModel( + name = entry, + isChecked = true, + isEnabled = true, + ) + } + backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX)) + } + } + + override fun onCleared() { + super.onCleared() + backupInput.peek()?.cleanupAsync() + } + + fun onItemClick(item: BackupEntryModel) { + val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } + map[item.name] = item.copy(isChecked = !item.isChecked) + map.validate() + availableEntries.value = map.values.sortedBy { it.name.ordinal } + } + + fun restore() { + launchLoadingJob { + val backup = backupInput.get() + val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { + if (it.isChecked) it.name else null + } + val result = CompositeResult() + val step = 1f / 5f + + progress.value = 0f + if (BackupEntry.Name.HISTORY in checkedItems) { + backup.getEntry(BackupEntry.Name.HISTORY)?.let { + result += repository.restoreHistory(it) + } + } + + progress.value += step + if (BackupEntry.Name.CATEGORIES in checkedItems) { + backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { + result += repository.restoreCategories(it) + } + } + + progress.value += step + if (BackupEntry.Name.FAVOURITES in checkedItems) { + backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { + result += repository.restoreFavourites(it) + } + } + + progress.value += step + if (BackupEntry.Name.BOOKMARKS in checkedItems) { + backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { + result += repository.restoreBookmarks(it) + } + } + + progress.value += step + if (BackupEntry.Name.SOURCES in checkedItems) { + backup.getEntry(BackupEntry.Name.SOURCES)?.let { + result += repository.restoreSources(it) + } + } + + progress.value = 1f + onRestoreDone.call(result) + } + } + + /** + * Check for inconsistent user selection + * Favorites cannot be restored without categories + */ + private fun MutableMap.validate() { + val favorites = this[BackupEntry.Name.FAVOURITES] ?: return + val categories = this[BackupEntry.Name.CATEGORIES] + if (categories?.isChecked == true) { + if (!favorites.isEnabled) { + this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true) + } + } else { + if (favorites.isEnabled) { + this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt new file mode 100644 index 0000000..b332d6d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt @@ -0,0 +1,76 @@ +package org.xtimms.tokusho.sections.settings.network + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.VpnLock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceItem +import org.xtimms.tokusho.core.components.PreferenceSwitch +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.components.icons.ArrowDecisionOutline +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.SSL_BYPASS + +const val NETWORK_DESTINATION = "network" + +@Composable +fun NetworkView( + navigateBack: () -> Unit, +) { + + var isSSLBypassEnabled by remember { + mutableStateOf(AppSettings.isSSLBypassEnabled()) + } + + ScaffoldWithTopAppBar( + title = stringResource(R.string.network), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + ) { + item { + PreferenceItem( + title = stringResource(id = R.string.proxy), + description = "", + icon = Icons.Outlined.ArrowDecisionOutline + ) + } + item { + PreferenceItem( + title = stringResource(id = R.string.dns_over_https), + description = "", + icon = Icons.Outlined.Dns + ) + } + item { + PreferenceSwitch( + title = stringResource(id = R.string.ignore_ssl_errors), + description = stringResource(id = R.string.ignore_ssl_errors_desc), + icon = Icons.Outlined.VpnLock, + isChecked = isSSLBypassEnabled, + ) { + isSSLBypassEnabled = !isSSLBypassEnabled + AppSettings.updateValue(SSL_BYPASS, isSSLBypassEnabled) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt index e866e31..d2fd8b7 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt @@ -8,16 +8,25 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Category +import androidx.compose.material.icons.outlined.Numbers import androidx.compose.material.icons.outlined.Update import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.PreferenceItem import org.xtimms.tokusho.core.components.PreferenceSubtitle +import org.xtimms.tokusho.core.components.PreferenceSwitch import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT import org.xtimms.tokusho.sections.shelf.ShelfViewModel const val SHELF_SETTINGS_DESTINATION = "shelf_settings" @@ -29,6 +38,12 @@ fun ShelfSettingsView( navigateToCategories: () -> Unit ) { + val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList()) + + var isMangaCountInTabsEnabled by remember { + mutableStateOf(AppSettings.isMangaCountInTabsEnabled()) + } + ScaffoldWithTopAppBar( title = stringResource(R.string.nav_shelf), navigateBack = navigateBack @@ -47,8 +62,8 @@ fun ShelfSettingsView( title = stringResource(id = R.string.edit_categories), description = pluralStringResource( id = R.plurals.categories_count, - count = shelfViewModel.uiState.value.categories.size, - shelfViewModel.uiState.value.categories.size + count = categories.size, + categories.size ), icon = Icons.Outlined.Category, onClick = { @@ -56,6 +71,16 @@ fun ShelfSettingsView( } ) } + item { + PreferenceSwitch( + title = stringResource(id = R.string.show_manga_count_in_tabs), + icon = Icons.Outlined.Numbers, + isChecked = isMangaCountInTabsEnabled, + onClick = { + isMangaCountInTabsEnabled = !isMangaCountInTabsEnabled + AppSettings.updateValue(TABS_MANGA_COUNT, isMangaCountInTabsEnabled) + }) + } item { PreferenceSubtitle(text = stringResource(id = R.string.updates)) } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt new file mode 100644 index 0000000..07722c8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/AddCategoryDialog.kt @@ -0,0 +1,48 @@ +package org.xtimms.tokusho.sections.settings.shelf.categories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NewLabel +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.ConfirmButton +import org.xtimms.tokusho.core.components.DismissButton + +@Composable +fun AddCategoryDialog(onDismissRequest: () -> Unit) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.add_category)) }, + icon = { Icon(Icons.Outlined.NewLabel, null) }, + text = { + Column { + OutlinedTextField( + modifier = Modifier.padding(bottom = 8.dp), + value = "", + onValueChange = { }, + label = { + Text(stringResource(id = R.string.name)) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + } + }, confirmButton = { + ConfirmButton { + onDismissRequest() + } + }, dismissButton = { + DismissButton { + onDismissRequest() + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt index 4194e2e..61e3eac 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoriesView.kt @@ -1,35 +1,52 @@ package org.xtimms.tokusho.sections.settings.shelf.categories +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.NewLabel import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar +import org.xtimms.tokusho.sections.shelf.ShelfViewModel +import org.xtimms.tokusho.utils.system.plus const val CATEGORIES_DESTINATION = "categories" +@OptIn(ExperimentalFoundationApi::class) @Composable fun CategoriesView( + shelfViewModel: ShelfViewModel = hiltViewModel(), navigateBack: () -> Unit, ) { + val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList()) + var showAddCategoryDialog by remember { mutableStateOf(false) } + val lazyListState = rememberLazyListState() + ScaffoldWithClassicTopAppBar( title = stringResource(R.string.edit_categories), floatingActionButton = { ExtendedFloatingActionButton( - onClick = { } + onClick = { + showAddCategoryDialog = true + } ) { Icon( imageVector = Icons.Outlined.NewLabel, @@ -44,13 +61,30 @@ fun CategoriesView( navigateBack = navigateBack ) { padding -> LazyColumn( - modifier = Modifier.padding(padding), - contentPadding = PaddingValues( - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - ) + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = padding + PaddingValues(horizontal = 16.dp) ) { - + itemsIndexed( + items = categories, + key = { _, category -> "category-${category.id}" }, + ) { index, category -> + CategoryListItem( + modifier = Modifier.animateItemPlacement(), + category = category, + canMoveUp = index != 0, + canMoveDown = index != categories.lastIndex, + onMoveUp = { }, + onMoveDown = { }, + onRename = { }, + onDelete = { }, + ) + } } } + if (showAddCategoryDialog) { + AddCategoryDialog( + onDismissRequest = { showAddCategoryDialog = false } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt new file mode 100644 index 0000000..d240771 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/CategoryListItem.kt @@ -0,0 +1,85 @@ +package org.xtimms.tokusho.sections.settings.shelf.categories + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.ArrowDropUp +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.sections.shelf.FavouriteTabModel + +@Composable +fun CategoryListItem( + category: FavouriteTabModel, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: (FavouriteTabModel) -> Unit, + onMoveDown: (FavouriteTabModel) -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onRename() } + .padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) + Text( + text = category.title, + modifier = Modifier + .padding(start = 16.dp), + ) + } + Row { + IconButton( + onClick = { onMoveUp(category) }, + enabled = canMoveUp, + ) { + Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null) + } + IconButton( + onClick = { onMoveDown(category) }, + enabled = canMoveDown, + ) { + Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onRename) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.action_rename_category), + ) + } + IconButton(onClick = onDelete) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_delete)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt new file mode 100644 index 0000000..c905811 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/categories/interactor/ReorderCategory.kt @@ -0,0 +1,61 @@ +package org.xtimms.tokusho.sections.settings.shelf.categories.interactor + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.xtimms.tokusho.core.model.FavouriteCategory +import org.xtimms.tokusho.data.repository.FavouritesRepository +import org.xtimms.tokusho.utils.lang.processLifecycleScope +import org.xtimms.tokusho.utils.lang.withNonCancellableContext +import java.util.Collections + +class ReorderCategory( + private val favouritesRepository: FavouritesRepository, +) { + + private val mutex = Mutex() + + suspend fun moveUp(category: FavouriteCategory): Result = await(category, MoveTo.UP) + + suspend fun moveDown(category: FavouriteCategory): Result = await(category, MoveTo.DOWN) + + private suspend fun await(category: FavouriteCategory, moveTo: MoveTo) = withNonCancellableContext { + mutex.withLock { + val categories = favouritesRepository.observeCategoriesForLibrary() + .map { it.toMutableList() } + .stateIn(processLifecycleScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()).value + + val currentIndex = categories.indexOfFirst { it.id == category.id } + if (currentIndex == -1) { + return@withNonCancellableContext Result.Unchanged + } + + val newPosition = when (moveTo) { + MoveTo.UP -> currentIndex - 1 + MoveTo.DOWN -> currentIndex + 1 + }.toInt() + + try { + Collections.swap(categories, currentIndex, newPosition) + Result.Success + } catch (e: Exception) { + Result.InternalError(e) + } + } + } + + sealed interface Result { + data object Success : Result + data object Unchanged : Result + data class InternalError(val error: Throwable) : Result + } + + private enum class MoveTo { + UP, + DOWN, + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt new file mode 100644 index 0000000..29d034e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsView.kt @@ -0,0 +1,101 @@ +package org.xtimms.tokusho.sections.settings.sources + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.NoAdultContent +import androidx.compose.material.icons.outlined.SettingsApplications +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceItem +import org.xtimms.tokusho.core.components.PreferenceSwitch +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.NSFW + +const val SOURCES_DESTINATION = "sources" + +@Composable +fun SourcesView( + viewModel: SourcesSettingsViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToSourcesCatalog: () -> Unit, + navigateToSourcesManagement: () -> Unit, +) { + + val context = LocalContext.current + val availableSourcesCount = viewModel.availableSourcesCount.collectAsState(-1).value + val enabledSourcesCount = viewModel.enabledSourcesCount.collectAsState(-1).value + val state by viewModel.viewStateFlow.collectAsStateWithLifecycle() + + var isNSFWEnabled by remember { + mutableStateOf(AppSettings.isNSFWEnabled()) + } + + ScaffoldWithTopAppBar( + title = stringResource(R.string.manga_sources), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + ) { + item { + PreferenceItem( + title = stringResource(id = R.string.manage_sources), + description = if (enabledSourcesCount >= 0) { + context.resources.getQuantityString( + R.plurals.items, + enabledSourcesCount, + enabledSourcesCount + ) + } else { + null + }, + icon = Icons.Outlined.SettingsApplications, + onClick = { navigateToSourcesManagement() } + ) + } + item { + PreferenceItem( + title = stringResource(id = R.string.sources_catalog), + description = if (availableSourcesCount >= 0) { + stringResource(R.string.available_d, availableSourcesCount) + } else { + null + }, + icon = Icons.Outlined.Apps, + onClick = { navigateToSourcesCatalog() } + ) + } + item { + PreferenceSwitch( + title = stringResource(id = R.string.disable_nsfw), + description = stringResource(id = R.string.disable_nsfw_desc), + icon = Icons.Outlined.NoAdultContent, + isChecked = isNSFWEnabled + ) { + isNSFWEnabled = !isNSFWEnabled + AppSettings.updateValue(NSFW, isNSFWEnabled) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt new file mode 100644 index 0000000..4184997 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/SourcesSettingsViewModel.kt @@ -0,0 +1,26 @@ +package org.xtimms.tokusho.sections.settings.sources + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import javax.inject.Inject + +@HiltViewModel +class SourcesSettingsViewModel @Inject constructor( + sourcesRepository: MangaSourcesRepository, +) : KotatsuBaseViewModel() { + + private val mutableViewStateFlow = MutableStateFlow(ViewState()) + val viewStateFlow = mutableViewStateFlow.asStateFlow() + + data class ViewState( + val enabledSourcesCount: Int = -1, + val availableSourcesCount: Int = -1, + ) + + val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() + + val availableSourcesCount = sourcesRepository.observeAvailableSourcesCount() +} diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt new file mode 100644 index 0000000..70a0d97 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt @@ -0,0 +1,44 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SourceCatalogItem( + source: String, + modifier: Modifier = Modifier, +) { + + Card( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 16.dp, + end = 16.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) + Text( + text = source, + modifier = Modifier + .padding(start = 16.dp), + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt new file mode 100644 index 0000000..277ab21 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt @@ -0,0 +1,30 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.core.model.ListModel + +sealed interface SourceCatalogItemModel : ListModel { + + data class Source( + val source: MangaSource, + val showSummary: Boolean, + ) : SourceCatalogItemModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is Source && other.source == source + } + } + + data class Hint( + val icon: ImageVector, + @StringRes val title: Int, + @StringRes val text: Int, + ) : SourceCatalogItemModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is Hint && other.title == title + } + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt new file mode 100644 index 0000000..281bfac --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogPage.kt @@ -0,0 +1,14 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import org.koitharu.kotatsu.parsers.model.ContentType +import org.xtimms.tokusho.core.model.ListModel + +data class SourceCatalogPage( + val type: ContentType, + val items: List, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourceCatalogPage && other.type == type + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt new file mode 100644 index 0000000..7d0721b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt @@ -0,0 +1,108 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.SearchOff +import androidx.room.InvalidationTracker +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.lifecycle.RetainedLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.model.ContentType +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.database.TABLE_SOURCES +import org.xtimms.tokusho.core.database.TokushoDatabase +import org.xtimms.tokusho.core.database.removeObserverAsync +import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import org.xtimms.tokusho.utils.lang.lifecycleScope + +class SourcesCatalogListProducer @AssistedInject constructor( + @Assisted private val locale: String?, + @Assisted private val contentType: ContentType, + @Assisted lifecycle: ViewModelLifecycle, + private val repository: MangaSourcesRepository, + private val database: TokushoDatabase, +) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener { + + private val scope = lifecycle.lifecycleScope + + private var query: String? = null + val list = MutableStateFlow(emptyList()) + + private var job = scope.launch(Dispatchers.Default) { + list.value = buildList() + } + + init { + scope.launch(Dispatchers.Default) { + database.invalidationTracker.addObserver(this@SourcesCatalogListProducer) + } + lifecycle.addOnClearedListener(this) + } + + override fun onCleared() { + database.invalidationTracker.removeObserverAsync(this) + } + + override fun onInvalidated(tables: Set) { + val prevJob = job + job = scope.launch(Dispatchers.Default) { + prevJob.cancelAndJoin() + list.update { buildList() } + } + } + + fun setQuery(value: String?) { + this.query = value + onInvalidated(emptySet()) + } + + private suspend fun buildList(): List { + val sources = repository.getDisabledSources().toMutableList() + when (val q = query) { + null -> sources.retainAll { it.contentType == contentType && it.locale == locale } + "" -> return emptyList() + else -> sources.retainAll { it.title.contains(q, ignoreCase = true) } + } + return if (sources.isEmpty()) { + listOf( + if (query == null) { + SourceCatalogItemModel.Hint( + icon = Icons.Outlined.SearchOff, + title = R.string.no_manga_sources, + text = R.string.no_manga_sources_catalog_text, + ) + } else { + SourceCatalogItemModel.Hint( + icon = Icons.Outlined.SearchOff, + title = R.string.nothing_found, + text = R.string.no_manga_sources_found, + ) + }, + ) + } else { + sources.sortBy { it.title } + sources.map { + SourceCatalogItemModel.Source( + source = it, + showSummary = query != null, + ) + } + } + } + + @AssistedFactory + interface Factory { + + fun create( + locale: String?, + contentType: ContentType, + lifecycle: ViewModelLifecycle, + ): SourcesCatalogListProducer + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt new file mode 100644 index 0000000..341fb9e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogTabs.kt @@ -0,0 +1,59 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaState +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.TabText + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun SourcesCatalogTabs( + categories: List, + pagerState: PagerState, + onTabItemClick: (Int) -> Unit, +) { + Column( + modifier = Modifier.zIndex(1f), + ) { + PrimaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + edgePadding = 0.dp, + // TODO: use default when width is fixed upstream + // https://issuetracker.google.com/issues/242879624 + divider = {}, + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = pagerState.currentPage == index, + onClick = { onTabItemClick(index) }, + text = { + TabText( + text = when (category.type) { + ContentType.MANGA -> stringResource(id = R.string.manga) + ContentType.COMICS -> stringResource(id = R.string.comics) + ContentType.HENTAI -> stringResource(id = R.string.hentai) + ContentType.OTHER -> stringResource(id = R.string.other) + else -> stringResource(id = R.string.unknown) + }, + ) + }, + unselectedContentColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + HorizontalDivider() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt new file mode 100644 index 0000000..e88f836 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt @@ -0,0 +1,46 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar + +const val CATALOG_DESTINATION = "catalog" + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SourcesCatalogView( + sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(), + navigateBack: () -> Unit, +) { + + val categories by sourcesCatalogViewModel.content.collectAsStateWithLifecycle(emptyList()) + + ScaffoldWithClassicTopAppBar( + title = stringResource(R.string.sources_catalog), + navigateBack = navigateBack + ) { padding -> + Column( + modifier = Modifier.padding(padding) + ) { + val pagerState = rememberPagerState(0) { categories.size } + val scope = rememberCoroutineScope() + if (categories.isNotEmpty()) { + SourcesCatalogTabs( + categories = categories, + pagerState = pagerState, + ) { scope.launch { pagerState.animateScrollToPage(it) } } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt new file mode 100644 index 0000000..21782fe --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -0,0 +1,87 @@ +package org.xtimms.tokusho.sections.settings.sources.catalog + +import androidx.annotation.MainThread +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.data.repository.MangaSourcesRepository +import org.xtimms.tokusho.utils.ReversibleAction +import org.xtimms.tokusho.utils.lang.MutableEventFlow +import org.xtimms.tokusho.utils.lang.call +import java.util.EnumMap +import java.util.EnumSet +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class SourcesCatalogViewModel @Inject constructor( + private val repository: MangaSourcesRepository, + private val listProducerFactory: SourcesCatalogListProducer.Factory, +) : KotatsuBaseViewModel() { + + private val lifecycle = RetainedLifecycleImpl() + private var searchQuery: String? = null + val onActionDone = MutableEventFlow() + val locales = repository.allMangaSources.mapToSet { it.locale } + val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales }) + + private val listProducers = locale.map { lc -> + createListProducers(lc) + }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value)) + + @OptIn(ExperimentalCoroutinesApi::class) + val content: StateFlow> = listProducers.flatMapLatest { + val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } } + combine>(flows, Array::toList) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + override fun onCleared() { + super.onCleared() + lifecycle.dispatchOnCleared() + } + + fun performSearch(query: String?) { + searchQuery = query + listProducers.value.forEach { (_, v) -> v.setQuery(query) } + } + + fun setLocale(value: String?) { + locale.value = value + } + + fun addSource(source: MangaSource) { + launchJob(Dispatchers.Default) { + val rollback = repository.setSourceEnabled(source, true) + onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) + } + } + + @MainThread + private fun createListProducers(lc: String?): Map { + val types = EnumSet.allOf(ContentType::class.java) + if (AppSettings.isNSFWEnabled()) { + types.remove(ContentType.HENTAI) + } + return types.associateWithTo(EnumMap(ContentType::class.java)) { type -> + listProducerFactory.create(lc, type, lifecycle).also { + it.setQuery(searchQuery) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt index 1ff8224..765a463 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/storage/StorageView.kt @@ -70,7 +70,7 @@ fun StorageView( PreferenceStorageItem( total = uiState.availableSpace.toFloat(), title = stringResource(id = R.string.saved_manga), - icon = Icons.Outlined.SdStorage + icon = Icons.Outlined.SdStorage, ) } item { diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt new file mode 100644 index 0000000..ecbe3ac --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/FavouriteTabModel.kt @@ -0,0 +1,14 @@ +package org.xtimms.tokusho.sections.shelf + +import org.xtimms.tokusho.core.model.ListModel + +data class FavouriteTabModel( + val id: Long, + val title: String, + val mangaCount: Int, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is FavouriteTabModel && other.id == id + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt new file mode 100644 index 0000000..32927a3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/LazyShelfGrid.kt @@ -0,0 +1,28 @@ +package org.xtimms.tokusho.sections.shelf + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.utils.system.plus + +@Composable +internal fun LazyShelfGrid( + modifier: Modifier = Modifier, + columns: Int, + contentPadding: PaddingValues, + content: LazyGridScope.() -> Unit, +) { + LazyVerticalGrid( + columns = if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns), + modifier = modifier, + contentPadding = contentPadding + PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt new file mode 100644 index 0000000..b7a3d32 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfGrid.kt @@ -0,0 +1,41 @@ +package org.xtimms.tokusho.sections.shelf + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastAny +import coil.ImageLoader +import org.xtimms.tokusho.core.components.MangaGridItem + +@Composable +internal fun ShelfGrid( + coil: ImageLoader, + items: List, + columns: Int, + contentPadding: PaddingValues, + selection: List, + onClick: (ShelfManga) -> Unit, + onLongClick: (ShelfManga) -> Unit, +) { + LazyShelfGrid( + modifier = Modifier.fillMaxSize(), + columns = columns, + contentPadding = contentPadding, + ) { + items( + items = items, + contentType = { "shelf_grid_item" }, + ) { shelfItem -> + val manga = shelfItem.manga + MangaGridItem( + coil = coil, + manga = manga, + isSelected = selection.fastAny { it.id == shelfItem.id }, + onLongClick = { onLongClick(shelfItem) }, + onClick = { onClick(shelfItem) }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt index f73d8e2..b770668 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt @@ -4,21 +4,6 @@ import org.koitharu.kotatsu.parsers.model.Manga data class ShelfManga( val manga: Manga, - val category: Long, - val totalChapters: Long, - val readCount: Long, - val bookmarkCount: Long, - val latestUpload: Long, - val chapterFetchedAt: Long, - val lastRead: Long, ) { val id: Long = manga.id - - val unreadCount - get() = totalChapters - readCount - - val hasBookmarks - get() = bookmarkCount > 0 - - val hasStarted = readCount > 0 } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt index cda5902..a13e66e 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt @@ -9,10 +9,13 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import coil.ImageLoader import org.xtimms.tokusho.R import org.xtimms.tokusho.core.screens.EmptyScreen import org.xtimms.tokusho.utils.system.plus @@ -20,12 +23,12 @@ import org.xtimms.tokusho.utils.system.plus @OptIn(ExperimentalFoundationApi::class) @Composable fun ShelfPager( + coil: ImageLoader, state: PagerState, contentPadding: PaddingValues, - hasActiveFilters: Boolean, searchQuery: String?, - onGlobalSearchClicked: () -> Unit, - getLibraryForPage: (Int) -> List, + getShelfForPage: (Int) -> List, + navigateToDetails: (ShelfManga) -> Unit, ) { HorizontalPager( modifier = Modifier.fillMaxSize(), @@ -36,31 +39,34 @@ fun ShelfPager( // To make sure only one offscreen page is being composed return@HorizontalPager } - val library = getLibraryForPage(page) - + val library = getShelfForPage(page) if (library.isEmpty()) { ShelfPagerEmptyScreen( searchQuery = searchQuery, - hasActiveFilters = hasActiveFilters, contentPadding = contentPadding, - onGlobalSearchClicked = onGlobalSearchClicked, ) return@HorizontalPager } + ShelfGrid( + coil = coil, + items = library, + columns = 2, + contentPadding = contentPadding, + selection = listOf(), + onClick = navigateToDetails, + onLongClick = { }, + ) } } @Composable private fun ShelfPagerEmptyScreen( searchQuery: String?, - hasActiveFilters: Boolean, contentPadding: PaddingValues, - onGlobalSearchClicked: () -> Unit, ) { val msg = when { !searchQuery.isNullOrEmpty() -> R.string.no_results_found - hasActiveFilters -> R.string.error_no_match else -> R.string.information_no_manga_category } @@ -71,7 +77,9 @@ private fun ShelfPagerEmptyScreen( .verticalScroll(rememberScrollState()), ) { EmptyScreen( - title = msg, + icon = Icons.Outlined.Close, + title = R.string.empty_here, + description = msg, modifier = Modifier.weight(1f), ) } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt index 24424b6..b6a8569 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt @@ -14,13 +14,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import org.xtimms.tokusho.core.components.TabText import org.xtimms.tokusho.core.model.FavouriteCategory +import org.xtimms.tokusho.core.prefs.AppSettings @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable internal fun ShelfTabs( - categories: List, + categories: List, pagerState: PagerState, - getNumberOfMangaForCategory: (FavouriteCategory) -> Int?, onTabItemClick: (Int) -> Unit, ) { Column( @@ -40,14 +40,13 @@ internal fun ShelfTabs( text = { TabText( text = category.title, - badgeCount = getNumberOfMangaForCategory(category), + badgeCount = if (AppSettings.isMangaCountInTabsEnabled()) category.mangaCount else null ) }, unselectedContentColor = MaterialTheme.colorScheme.onSurface, ) } } - HorizontalDivider() } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt deleted file mode 100644 index 8466d85..0000000 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfUiState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.xtimms.tokusho.sections.shelf - -import org.xtimms.tokusho.core.base.state.UiState -import org.xtimms.tokusho.core.model.FavouriteCategory - -data class ShelfUiState( - val categories: List = emptyList(), - override val isLoading: Boolean = false, - override val message: String? = null, -) : UiState() { - - override fun setLoading(value: Boolean) = copy(isLoading = value) - override fun setMessage(value: String?) = copy(message = value) -} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt index a2515ce..919528e 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt @@ -11,55 +11,64 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.xtimms.tokusho.core.collapsable +import org.xtimms.tokusho.core.components.PullRefresh import org.xtimms.tokusho.core.model.FavouriteCategory import org.xtimms.tokusho.core.model.ShelfCategory +import kotlin.time.Duration.Companion.seconds const val SHELF_DESTINATION = "shelf" @Composable fun ShelfView( + coil: ImageLoader, currentPage: () -> Int, showPageTabs: Boolean, - getNumberOfMangaForCategory: (FavouriteCategory) -> Int?, - getLibraryForPage: (Int) -> List, topBarHeightPx: Float, padding: PaddingValues, + navigateToDetails: (Long) -> Unit, + onRefresh: (FavouriteTabModel?) -> Boolean, ) { - val viewModel: ShelfViewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() ShelfViewContent( - uiState = uiState, + coil = coil, currentPage = currentPage, showPageTabs = showPageTabs, - getNumberOfMangaForCategory = getNumberOfMangaForCategory, - getLibraryForPage = getLibraryForPage, topBarHeightPx = topBarHeightPx, - padding = padding + padding = padding, + navigateToDetails = navigateToDetails, + onRefresh = onRefresh ) } @OptIn(ExperimentalFoundationApi::class) @Composable fun ShelfViewContent( - uiState: ShelfUiState, + coil: ImageLoader, + viewModel: ShelfViewModel = hiltViewModel(), currentPage: () -> Int, showPageTabs: Boolean, - getNumberOfMangaForCategory: (FavouriteCategory) -> Int?, - getLibraryForPage: (Int) -> List, topBarHeightPx: Float, topBarOffsetY: Animatable = Animatable(0f), padding: PaddingValues, + navigateToDetails: (Long) -> Unit, + onRefresh: (FavouriteTabModel?) -> Boolean, ) { val scrollState = rememberScrollState() + val categories by viewModel.categories.collectAsStateWithLifecycle(emptyList()) + val mangas by viewModel.mangas.collectAsStateWithLifecycle(emptyList()) + Column( modifier = Modifier .collapsable( @@ -69,31 +78,46 @@ fun ShelfViewContent( ) .padding(padding) ) { - val coercedCurrentPage = remember { currentPage().coerceAtMost(uiState.categories.lastIndex) } - val pagerState = rememberPagerState(coercedCurrentPage) { uiState.categories.size } + val pagerState = rememberPagerState(0) { categories.size } val scope = rememberCoroutineScope() - if (showPageTabs && uiState.categories.size > 1) { - LaunchedEffect(uiState.categories) { - if (uiState.categories.size <= pagerState.currentPage) { - pagerState.scrollToPage(uiState.categories.size - 1) - } + + var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } + + if (categories.isNotEmpty()) { + if (showPageTabs) { + ShelfTabs( + categories = categories, + pagerState = pagerState, + ) { scope.launch { pagerState.animateScrollToPage(it) } } } - ShelfTabs( - categories = uiState.categories, - pagerState = pagerState, - getNumberOfMangaForCategory = getNumberOfMangaForCategory, - ) { scope.launch { pagerState.animateScrollToPage(it) } } } - ShelfPager( - state = pagerState, - contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()), - hasActiveFilters = false, - searchQuery = "", - onGlobalSearchClicked = { }, - getLibraryForPage = getLibraryForPage, - ) - } -} + val onClickManga = { manga: ShelfManga -> + navigateToDetails(manga.id) + } -typealias ShelfMap = Map> \ No newline at end of file + PullRefresh( + refreshing = isRefreshing, + onRefresh = { + val started = onRefresh(categories[currentPage()]) + if (!started) return@PullRefresh + scope.launch { + // Fake refresh status but hide it after a second as it's a long running task + isRefreshing = true + delay(1.seconds) + isRefreshing = false + } + }, + enabled = { true } + ) { + ShelfPager( + coil = coil, + state = pagerState, + contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()), + searchQuery = "", + getShelfForPage = { mangas }, + navigateToDetails = onClickManga + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt index 8e78633..1e37a65 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfViewModel.kt @@ -5,36 +5,50 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel +import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel import org.xtimms.tokusho.data.repository.FavouritesRepository +import org.xtimms.tokusho.utils.lang.mapItems import javax.inject.Inject @HiltViewModel class ShelfViewModel @Inject constructor( - private val favouritesRepository: FavouritesRepository, -) : BaseViewModel() { + favouritesRepository: FavouritesRepository, +) : KotatsuBaseViewModel() { + + private val mangasStateFlow = favouritesRepository.observeAll(1) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + val mangaCount = favouritesRepository.observeMangaCount() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + + val categories = categoriesStateFlow + .filterNotNull() + .mapItems { FavouriteTabModel(it.id, it.title, mangaCount.value ?: 0) } + .distinctUntilChanged() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + val mangas = mangasStateFlow + .filterNotNull() + .mapItems { ShelfManga(it) } + .distinctUntilChanged() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + val isEmpty = categoriesStateFlow.map { it?.isEmpty() == true }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) - init { - launchJob(Dispatchers.Default) { - mutableUiState.update { - it.copy( - categories = categoriesStateFlow.value ?: emptyList() - ) - } - } - } - - override val mutableUiState = MutableStateFlow(ShelfUiState()) - } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt deleted file mode 100644 index 6ae9d81..0000000 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.xtimms.tokusho.sections.shelf.ext - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import org.xtimms.tokusho.R -import org.xtimms.tokusho.core.model.ShelfCategory - -val ShelfCategory.visualName: String - @Composable - get() = when { - isSystemCategory -> stringResource(R.string.label_default) - else -> name - } - -fun ShelfCategory.visualName(context: Context): String = - when { - isSystemCategory -> context.getString(R.string.label_default) - else -> name - } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt b/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt index 1b2c353..29e0c14 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/stats/ChaptersChart.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource -import org.xtimms.shiki.ui.theme.colorMax -import org.xtimms.shiki.ui.theme.colorMin +import org.xtimms.tokusho.ui.theme.colorMax +import org.xtimms.tokusho.ui.theme.colorMin import org.xtimms.tokusho.ui.theme.TokushoTheme import org.xtimms.tokusho.utils.material.combineColors import org.xtimms.tokusho.utils.material.harmonize diff --git a/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt b/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt index 9b4b499..bcf169b 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/stats/MinMaxReadCard.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Label -import androidx.compose.material.icons.outlined.Label import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -22,13 +21,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource -import org.xtimms.shiki.ui.theme.colorMax -import org.xtimms.shiki.ui.theme.colorMin +import org.xtimms.tokusho.ui.theme.colorMax +import org.xtimms.tokusho.ui.theme.colorMin import org.xtimms.tokusho.R import org.xtimms.tokusho.utils.material.combineColors import org.xtimms.tokusho.utils.material.harmonize import org.xtimms.tokusho.utils.material.toPalette -import java.math.BigDecimal @Composable fun MinMaxReadCard( diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt index 57d0697..a3967d4 100644 --- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package org.xtimms.shiki.ui.theme +package org.xtimms.tokusho.ui.theme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt index 280b1a0..1551ae6 100644 --- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextDirection @@ -19,6 +20,10 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.android.material.color.MaterialColors import org.xtimms.tokusho.ui.monet.dynamicColorScheme +fun Color.disabledIconOpacity(): Color { + return this.copy(alpha = 0.38f) +} + fun Color.applyOpacity(enabled: Boolean): Color { return if (enabled) this else this.copy(alpha = 0.62f) } @@ -41,7 +46,6 @@ private tailrec fun Context.findWindow(): Window? = @Composable fun TokushoTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ isHighContrastModeEnabled: Boolean = false, isDynamicColorEnabled: Boolean = false, content: @Composable () -> Unit @@ -71,7 +75,7 @@ fun TokushoTheme( ) { MaterialTheme( colorScheme = colorScheme, - typography = Typography, + typography = Typography(LocalContext.current), content = content ) } @@ -83,7 +87,7 @@ fun PreviewThemeLight( ) { MaterialTheme( colorScheme = dynamicColorScheme(), - typography = Typography, + typography = Typography(LocalContext.current), content = content ) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt index 0740336..9d9edea 100644 --- a/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt @@ -1,5 +1,6 @@ package org.xtimms.tokusho.ui.theme +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -12,30 +13,89 @@ import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp +fun Typography(context: Context): Typography { + + fun getFont(weight: Int) = FontFamily( + Font( + "font/manrope_variable.ttf", context.assets, + variationSettings = FontVariation.Settings( + FontVariation.weight(weight), + ), + ) ) -) -val preferenceTitle = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 20.sp, lineHeight = 24.sp, - lineBreak = LineBreak.Paragraph, -) + return Typography( + displayLarge = TextStyle( + fontFamily = getFont(650), + fontSize = 57.sp + ), + displayMedium = TextStyle( + fontFamily = getFont(900), + fontSize = 45.sp + ), + displaySmall = TextStyle( + fontFamily = getFont(800), + fontSize = 22.sp + ), + headlineLarge = TextStyle( + fontFamily = getFont(800), + fontSize = 36.sp + ), + headlineMedium = TextStyle( + fontFamily = getFont(700), + fontSize = 28.sp + ), + headlineSmall = TextStyle( + fontFamily = getFont(650), + fontSize = 24.sp + ), + titleLarge = TextStyle( + fontFamily = getFont(700), + fontSize = 22.sp + ), + titleMedium = TextStyle( + fontFamily = getFont(600), + fontSize = 16.sp + ), + titleSmall = TextStyle( + fontFamily = getFont(650), + fontSize = 14.sp + ), + bodyLarge = TextStyle( + fontFamily = getFont(700), + fontSize = 16.sp + ), + bodyMedium = TextStyle( + fontFamily = getFont(600), + fontSize = 14.sp + ), + bodySmall = TextStyle( + fontFamily = getFont(500), + fontSize = 14.sp + ), + labelLarge = TextStyle( + fontFamily = getFont(700), + fontSize = 14.sp + ), + labelMedium = TextStyle( + fontFamily = getFont(700), + fontSize = 12.sp + ), + labelSmall = TextStyle( + fontFamily = getFont(600), + fontSize = 11.sp + ) + ) +} @Composable fun FontCard(family: String, size: String, style: TextStyle) { diff --git a/app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt b/app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt new file mode 100644 index 0000000..40c794c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/AlphanumComparator.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.utils + +class AlphanumComparator : Comparator { + + override fun compare(s1: String?, s2: String?): Int { + if (s1 == null || s2 == null) { + return 0 + } + var thisMarker = 0 + var thatMarker = 0 + val s1Length = s1.length + val s2Length = s2.length + while (thisMarker < s1Length && thatMarker < s2Length) { + val thisChunk = getChunk(s1, s1Length, thisMarker) + thisMarker += thisChunk.length + val thatChunk = getChunk(s2, s2Length, thatMarker) + thatMarker += thatChunk.length + // If both chunks contain numeric characters, sort them numerically + var result: Int + if (thisChunk[0].isDigit() && thatChunk[0].isDigit()) { // Simple chunk comparison by length. + val thisChunkLength = thisChunk.length + result = thisChunkLength - thatChunk.length + // If equal, the first different number counts + if (result == 0) { + for (i in 0 until thisChunkLength) { + result = thisChunk[i] - thatChunk[i] + if (result != 0) { + return result + } + } + } + } else { + result = thisChunk.compareTo(thatChunk) + } + if (result != 0) return result + } + return s1Length - s2Length + } + + private fun getChunk(s: String, slength: Int, cmarker: Int): String { + var marker = cmarker + val chunk = StringBuilder() + var c = s[marker] + chunk.append(c) + marker++ + if (c.isDigit()) { + while (marker < slength) { + c = s[marker] + if (!c.isDigit()) break + chunk.append(c) + marker++ + } + } else { + while (marker < slength) { + c = s[marker] + if (c.isDigit()) break + chunk.append(c) + marker++ + } + } + return chunk.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt new file mode 100644 index 0000000..b7c2223 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex.kt @@ -0,0 +1,58 @@ +package org.xtimms.tokusho.utils + +import androidx.collection.ArrayMap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.coroutineContext + +@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2")) +class CompositeMutex : Set { + + private val state = ArrayMap>() + private val mutex = Mutex() + + override val size: Int + get() = state.size + + override fun contains(element: T): Boolean { + return state.containsKey(element) + } + + override fun containsAll(elements: Collection): Boolean { + return elements.all { x -> state.containsKey(x) } + } + + override fun isEmpty(): Boolean { + return state.isEmpty() + } + + override fun iterator(): Iterator { + return state.keys.iterator() + } + + suspend fun lock(element: T) { + while (coroutineContext.isActive) { + waitForRemoval(element) + mutex.withLock { + if (state[element] == null) { + state[element] = MutableStateFlow(false) + return + } + } + } + } + + fun unlock(element: T) { + checkNotNull(state.remove(element)) { + "CompositeMutex is not locked for $element" + }.value = true + } + + private suspend fun waitForRemoval(element: T) { + val flow = state[element] ?: return + flow.first { it } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt new file mode 100644 index 0000000..c20edfe --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CompositeMutex2.kt @@ -0,0 +1,43 @@ +package org.xtimms.tokusho.utils + +import androidx.collection.ArrayMap +import kotlinx.coroutines.sync.Mutex + +class CompositeMutex2 : Set { + + private val delegates = ArrayMap() + + override val size: Int + get() = delegates.size + + override fun contains(element: T): Boolean { + return delegates.containsKey(element) + } + + override fun containsAll(elements: Collection): Boolean { + return elements.all { x -> delegates.containsKey(x) } + } + + override fun isEmpty(): Boolean { + return delegates.isEmpty() + } + + override fun iterator(): Iterator { + return delegates.keys.iterator() + } + + suspend fun lock(element: T) { + val mutex = synchronized(delegates) { + delegates.getOrPut(element) { + Mutex() + } + } + mutex.lock() + } + + fun unlock(element: T) { + synchronized(delegates) { + delegates.remove(element)?.unlock() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt index a80de87..91c8dbf 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt @@ -2,9 +2,10 @@ package org.xtimms.tokusho.utils import android.content.Context import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.xtimms.tokusho.BuildConfig import org.xtimms.tokusho.utils.lang.withNonCancellableContext -import org.xtimms.tokusho.utils.lang.withUIContext import org.xtimms.tokusho.utils.system.createFileInCacheDir import org.xtimms.tokusho.utils.system.getUriCompat import org.xtimms.tokusho.utils.system.toShareIntent @@ -25,7 +26,7 @@ class CrashLogUtil( val uri = file.getUriCompat(context) context.startActivity(uri.toShareIntent(context, "text/plain")) } catch (e: Throwable) { - withUIContext { context.toast("Failed to get logs") } + withContext(Dispatchers.IO) { context.toast("Failed to get logs") } } } diff --git a/app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt b/app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt new file mode 100644 index 0000000..b4ed81d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/FileSequence.kt @@ -0,0 +1,15 @@ +package org.xtimms.tokusho.utils + +import org.xtimms.tokusho.utils.iterator.CloseableIterator +import org.xtimms.tokusho.utils.iterator.MappingIterator +import java.io.File +import java.nio.file.Files +import java.nio.file.Path + +class FileSequence(private val dir: File) : Sequence { + + override fun iterator(): Iterator { + val stream = Files.newDirectoryStream(dir.toPath()) + return CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt b/app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt new file mode 100644 index 0000000..e96eddd --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/ImageFileFilter.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.utils + +import java.io.File + +fun hasImageExtension(string: String): Boolean { + val ext = string.substringAfterLast('.', "") + return ext.equals("png", ignoreCase = true) || ext.equals("jpg", ignoreCase = true) + || ext.equals("jpeg", ignoreCase = true) || ext.equals("webp", ignoreCase = true) +} + +fun hasImageExtension(file: File) = hasImageExtension(file.name) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt b/app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt new file mode 100644 index 0000000..01be172 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/RetainedLifecycleCoroutineScope.kt @@ -0,0 +1,23 @@ +package org.xtimms.tokusho.utils + +import dagger.hilt.android.lifecycle.RetainedLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlin.coroutines.CoroutineContext + +class RetainedLifecycleCoroutineScope( + val lifecycle: RetainedLifecycle, +) : CoroutineScope, RetainedLifecycle.OnClearedListener { + + override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate + + init { + lifecycle.addOnClearedListener(this) + } + + override fun onCleared() { + coroutineContext.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt b/app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt new file mode 100644 index 0000000..bd9a67f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/ReversibleAction.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.utils + +import androidx.annotation.StringRes + +class ReversibleAction( + @StringRes val stringResId: Int, + val handle: ReversibleHandle?, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt b/app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt new file mode 100644 index 0000000..ddc91ad --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/StringArrayNavType.kt @@ -0,0 +1,21 @@ +package org.xtimms.tokusho.utils + +import android.os.Bundle +import androidx.navigation.NavType +import kotlinx.serialization.json.Json + +object StringArrayNavType : NavType>(isNullableAllowed = false) { + + override fun get(bundle: Bundle, key: String): Array? { + return bundle.getStringArray(key) + } + + override fun parseValue(value: String): Array { + return Json.decodeFromString(value) + } + + override fun put(bundle: Bundle, key: String, value: Array) { + bundle.putStringArray(key, value) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt new file mode 100644 index 0000000..0fbf111 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/Bitmap.kt @@ -0,0 +1,21 @@ +package org.xtimms.tokusho.utils.composable + +import android.content.res.Resources +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap + +@Composable +fun rememberResourceBitmapPainter(@DrawableRes id: Int): BitmapPainter { + val context = LocalContext.current + return remember(id) { + val drawable = ContextCompat.getDrawable(context, id) + ?: throw Resources.NotFoundException() + BitmapPainter(drawable.toBitmap().asImageBitmap()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt index 2fe6b9e..811aa20 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/LazyListState.kt @@ -5,7 +5,10 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow /** @@ -68,4 +71,60 @@ fun LazyGridState.onBottomReached( snapshotFlow { shouldLoadMore.value } .collect { if (it) onLoadMore() } } +} + +@Composable +fun LazyListState.isScrolledToStart(): Boolean { + return remember { + derivedStateOf { + val firstItem = layoutInfo.visibleItemsInfo.firstOrNull() + firstItem == null || firstItem.offset == layoutInfo.viewportStartOffset + } + }.value +} + +@Composable +fun LazyListState.isScrolledToEnd(): Boolean { + return remember { + derivedStateOf { + val lastItem = layoutInfo.visibleItemsInfo.lastOrNull() + lastItem == null || lastItem.size + lastItem.offset <= layoutInfo.viewportEndOffset + } + }.value +} + +@Composable +fun LazyListState.isScrollingUp(): Boolean { + var previousIndex by remember { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember { mutableIntStateOf(firstVisibleItemScrollOffset) } + return remember { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + +@Composable +fun LazyListState.isScrollingDown(): Boolean { + var previousIndex by remember { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember { mutableIntStateOf(firstVisibleItemScrollOffset) } + return remember { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex < firstVisibleItemIndex + } else { + previousScrollOffset <= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt b/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt index 3a342d2..a6de146 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/composable/Modifier.kt @@ -3,12 +3,27 @@ package org.xtimms.tokusho.utils.composable import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind import org.xtimms.tokusho.utils.material.SecondaryItemAlpha +fun Modifier.selectedBackground(isSelected: Boolean): Modifier = if (isSelected) { + composed { + val alpha = if (isSystemInDarkTheme()) 0.16f else 0.22f + val color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha) + Modifier.drawBehind { + drawRect(color) + } + } +} else { + this +} + fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha) @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt b/app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt new file mode 100644 index 0000000..8e3dec1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/iterator/CloseableIterator.kt @@ -0,0 +1,36 @@ +package org.xtimms.tokusho.utils.iterator + +import okhttp3.internal.closeQuietly +import okio.Closeable + +class CloseableIterator( + private val upstream: Iterator, + private val closeable: Closeable, +) : Iterator, Closeable { + + private var isClosed = false + + override fun hasNext(): Boolean { + val result = upstream.hasNext() + if (!result) { + close() + } + return result + } + + override fun next(): T { + try { + return upstream.next() + } catch (e: NoSuchElementException) { + close() + throw e + } + } + + override fun close() { + if (!isClosed) { + closeable.closeQuietly() + isClosed = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt b/app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt new file mode 100644 index 0000000..98659db --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/iterator/MappingIterator.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.utils.iterator + +class MappingIterator( + private val upstream: Iterator, + private val mapper: (T) -> R, +) : Iterator { + + override fun hasNext(): Boolean = upstream.hasNext() + + override fun next(): R = mapper(upstream.next()) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt new file mode 100644 index 0000000..7526919 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Android.kt @@ -0,0 +1,16 @@ +package org.xtimms.tokusho.utils.lang + +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.core.app.ActivityOptionsCompat + +fun ActivityResultLauncher.tryLaunch( + input: I, + options: ActivityOptionsCompat? = null, +): Boolean = runCatching { + launch(input, options) +}.onFailure { e -> + e.printStackTrace() +}.isSuccess + +fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt new file mode 100644 index 0000000..d4dbf9f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Collections.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.utils.lang + +fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { + this as ArrayList +} else { + ArrayList(this) +} + +fun Sequence.toListSorted(comparator: Comparator): List { + return toMutableList().apply { sortWith(comparator) } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt index 865b627..722da55 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt @@ -3,70 +3,35 @@ package org.xtimms.tokusho.utils.lang import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - -/** - * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. - * - * **Possible replacements** - * - suspend function - * - custom scope like view or presenter scope - */ -@DelicateCoroutinesApi -fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = - GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) - -/** - * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. - * - * **Possible replacements** - * - suspend function - * - custom scope like view or presenter scope - */ -@DelicateCoroutinesApi -fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = - GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) - -/** - * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. - * - * **Possible replacements** - * - suspend function - * - custom scope like view or presenter scope - */ -@DelicateCoroutinesApi -fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = - GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) - -fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = - launch(Dispatchers.Main, block = block) - -fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = - launch(Dispatchers.IO, block = block) - -fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job = - launchIO { withContext(NonCancellable, block) } - -suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext( - Dispatchers.Main, - block, -) - -suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext( - Dispatchers.IO, - block, -) +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = withContext(NonCancellable, block) val processLifecycleScope: LifecycleCoroutineScope - inline get() = ProcessLifecycleOwner.get().lifecycleScope \ No newline at end of file + inline get() = ProcessLifecycleOwner.get().lifecycleScope + +val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope + inline get() = RetainedLifecycleCoroutineScope(this) + +@OptIn(ExperimentalCoroutinesApi::class) +fun Deferred.peek(): T? = if (isCompleted) { + runCatchingCancellable { + getCompleted() + }.getOrNull() +} else { + null +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt new file mode 100644 index 0000000..02e285f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/FlowObserver.kt @@ -0,0 +1,37 @@ +package org.xtimms.tokusho.utils.lang + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.xtimms.tokusho.utils.Event + +fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) { + val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT + owner.lifecycleScope.launch(start = start) { + collect(collector) + } +} + +fun Flow.observe(owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector) { + owner.lifecycleScope.launch { + owner.lifecycle.repeatOnLifecycle(minState) { + collect(collector) + } + } +} + +fun Flow?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector) { + owner.lifecycleScope.launch { + owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + collect { + it?.consume(collector) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt index ce71ae3..1a60d97 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt @@ -19,4 +19,7 @@ inline val String.stringState inline val String.intState @Composable get() = remember { mutableIntStateOf(this.getInt()) - } \ No newline at end of file + } + +// clamp(3.5f, 6.7f) > [0.0f, 1.0f] +fun Float.clamp(min: Float, max: Float): Float = (1f - ((this.coerceIn(min, max) - min) / (max - min))) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt index 39efea2..e1f2001 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt @@ -25,4 +25,9 @@ fun CharSequence.sanitize(): CharSequence { return filterNot { c -> c.isReplacement() } } -fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' \ No newline at end of file +fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' + +fun Float?.toStringPositiveValueOrUnknown() = + if (this == 0f) "─" else this.toStringOrUnknown() + +fun Float?.toStringOrUnknown() = this?.toString() ?: "─" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt new file mode 100644 index 0000000..68f091a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/WorkManager.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.utils.lang + +import android.annotation.SuppressLint +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await + +@SuppressLint("RestrictedApi") +suspend fun WorkManager.awaitUniqueWorkInfoByName(name: String): List { + return getWorkInfosForUniqueWork(name).await().orEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt index ef662af..e343b21 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt @@ -2,12 +2,21 @@ package org.xtimms.tokusho.utils.system import android.content.Context import android.net.Uri +import android.os.Build import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext import org.xtimms.tokusho.BuildConfig +import org.xtimms.tokusho.utils.FileSequence import java.io.File +import java.io.FileFilter +import java.nio.file.attribute.BasicFileAttributes +import java.util.zip.ZipEntry +import java.util.zip.ZipFile import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.PathWalkOption +import kotlin.io.path.readAttributes import kotlin.io.path.walk fun File.subdir(name: String) = File(this, name).also { @@ -21,10 +30,34 @@ fun File.getUriCompat(context: Context): Uri { fun Context.getFileProvider() = "$packageName.provider" suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) { - walkCompat().sumOf { it.length() } + walkCompat(includeDirectories = false).sumOf { it.length() } } @OptIn(ExperimentalPathApi::class) -fun File.walkCompat() = +fun File.walkCompat(includeDirectories: Boolean): Sequence { // Use lazy loading on Android 8.0 and later - toPath().walk().map { it.toFile() } + val walk = if (includeDirectories) { + toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES) + } else { + toPath().walk() + } + return walk.map { it.toFile() } +} + +fun File.children() = FileSequence(this) + +suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { + delete() || deleteRecursively() +} + +val File.creationTime + get() = toPath().readAttributes().creationTime().toMillis() + +fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().use { + it.readText() +} + +fun Sequence.filterWith(filter: FileFilter): Sequence = filter { f -> filter.accept(f) } + +fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } +fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt index 301e31c..5208d0c 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt @@ -43,4 +43,23 @@ private const val RUSSIAN = 2 // Sorted alphabetically val languageMap: Map = mapOf( RUSSIAN to "ru", -) \ No newline at end of file +) + +operator fun LocaleListCompat.iterator(): ListIterator = LocaleListCompatIterator(this) + +private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator { + + private var index = 0 + + override fun hasNext() = index < list.size() + + override fun hasPrevious() = index > 0 + + override fun next() = list.get(index++) ?: throw NoSuchElementException() + + override fun nextIndex() = index + + override fun previous() = list.get(--index) ?: throw NoSuchElementException() + + override fun previousIndex() = index - 1 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt new file mode 100644 index 0000000..f55c270 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt @@ -0,0 +1,4 @@ +package org.xtimms.tokusho.utils.system + +const val URI_SCHEME_FILE = "file" +const val URI_SCHEME_ZIP = "file+zip" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt b/app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt new file mode 100644 index 0000000..fc7711b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/work/PeriodicWorkScheduler.kt @@ -0,0 +1,10 @@ +package org.xtimms.tokusho.work + +interface PeriodicWorkScheduler { + + suspend fun schedule() + + suspend fun unschedule() + + suspend fun isScheduled(): Boolean +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_error_outline_24.xml b/app/src/main/res/drawable/baseline_error_outline_24.xml new file mode 100644 index 0000000..7816afd --- /dev/null +++ b/app/src/main/res/drawable/baseline_error_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/cover_error.xml b/app/src/main/res/drawable/cover_error.xml new file mode 100644 index 0000000..cc25202 --- /dev/null +++ b/app/src/main/res/drawable/cover_error.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cover_loading.xml b/app/src/main/res/drawable/cover_loading.xml new file mode 100644 index 0000000..ae7ad1a --- /dev/null +++ b/app/src/main/res/drawable/cover_loading.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/roboto_flex_regular.ttf b/app/src/main/res/font/roboto_flex_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..f857ae9c569eb2caa8700cab80905ae355e41ecb GIT binary patch literal 109860 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 + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..08622b7 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,4 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF + #1F888888 \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 36efbd3..64b1623 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -1,5 +1,9 @@ + + %1$d item + %1$d items + %1$d hour %1$d hours diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23ba5a7..a2767e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,4 +124,84 @@ Edit categories Updates Add + Category name + Add category + Name + Network + Proxy, DNS over HTTPS + Proxy + DNS over HTTPS + You have no categories + Delete + Rename category + Reading + Completed + Dropped + Popular + Manual + Disable NSFW + Disable NSFW sources and hide adult manga from list if possible + Manga sources + TODO + Backup and restore + Enable periodic backups + Backup creation frequency + Backup output directory + Actions + General + Create data backup + Restore from backup + You can create backup of your history and favourites and restore it + Restore previously created backup + You should keep copies of backups in other places as well. Backups may contain sensitive data, be careful if sharing. + Operation is not supported + Storage permission issue + Directories outside Download and Documents are not supported + History + Remote sources + Select backup file + No file selected + Backup or restore may not function properly if MIUI Optimization is disabled + Restore + The date the backup was created + Create backup file + Backup saved + Supports Kotatsu backups + Tokusho can handle Kotatsu backups as well + Open source licenses + No manga sources + Enable manga sources to read manga online + Catalog + %1$d of %2$d on + Sources catalog + Available: %1$d + What you read will be displayed here + Find what to read in the \"Explore\" section + Nothing found + Try to reformulate the query + It\'s kind of empty here + Manage sources + Ignore SSL errors + It may help in some cases + Show the number of manga in categories + Clear all + Description + Source enabled + There are no sources available in this section, or all of it might have been already added.\nStay tuned + No available manga sources found by your query + Bookmarked + Unread + Score + Read + Not in favourites + Manga + Comics + Hentai + Other + Related manga + Chapters + An error has occurred + In favourites + In shelf + Add to shelf \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index df42be7..ef81eb2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,10 @@ - \ No newline at end of file diff --git a/app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt b/app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt new file mode 100644 index 0000000..3101589 --- /dev/null +++ b/app/src/test/java/org/xtimms/tokusho/data/repository/backup/JsonSerializerTest.kt @@ -0,0 +1,98 @@ +package org.xtimms.tokusho.data.repository.backup + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity +import org.xtimms.tokusho.core.database.entity.FavouriteEntity +import org.xtimms.tokusho.core.database.entity.HistoryEntity +import org.xtimms.tokusho.core.database.entity.MangaEntity +import org.xtimms.tokusho.core.database.entity.TagEntity +import java.util.concurrent.TimeUnit + +class JsonSerializerTest { + + @Test + fun toFavouriteEntity() { + val entity = FavouriteEntity( + mangaId = 40, + categoryId = 20, + sortKey = 1, + createdAt = System.currentTimeMillis(), + deletedAt = 0L, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toFavouriteEntity() + assertEquals(entity, result) + } + + @Test + fun toMangaEntity() { + val entity = MangaEntity( + id = 231, + title = "Lorem Ipsum", + altTitle = "Lorem Ispum 2", + url = "erw", + publicUrl = "hthth", + rating = 0.78f, + isNsfw = true, + coverUrl = "5345", + largeCoverUrl = null, + state = MangaState.FINISHED.name, + author = "RERE", + source = MangaSource.DUMMY.name, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toMangaEntity() + assertEquals(entity, result) + } + + @Test + fun toTagEntity() { + val entity = TagEntity( + id = 934023534, + title = "Adventure", + key = "adventure", + source = MangaSource.DUMMY.name, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toTagEntity() + assertEquals(entity, result) + } + + @Test + fun toHistoryEntity() { + val entity = HistoryEntity( + mangaId = 304135341, + createdAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6), + updatedAt = System.currentTimeMillis(), + chapterId = 29014843034, + page = 35, + scroll = 24.0f, + percent = 0.6f, + deletedAt = 0L, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toHistoryEntity() + assertEquals(entity, result) + } + + @Test + fun toFavouriteCategoryEntity() { + val entity = FavouriteCategoryEntity( + categoryId = 142, + createdAt = System.currentTimeMillis(), + sortKey = 14, + title = "Read later", + order = SortOrder.RATING.name, + track = false, + isVisibleInLibrary = true, + deletedAt = 0L, + ) + val json = JsonSerializer(entity).toJson() + val result = JsonDeserializer(json).toFavouriteCategoryEntity() + assertEquals(entity, result) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a1c2009..2ebca75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("org.jetbrains.kotlin.android") version "1.9.0" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" apply false id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false + id("com.mikepenz.aboutlibraries.plugin") version "10.10.0" apply false } buildscript {