From 8617f18f9011ca16611aecff937c185b5652d6b2 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sat, 3 Feb 2024 13:32:24 +0300 Subject: [PATCH] Initial commit --- .gitignore | 115 +++ .idea/.gitignore | 3 + .idea/.name | 1 + .idea/compiler.xml | 6 + .idea/deploymentTargetDropDown.xml | 23 + .idea/discord.xml | 7 + .idea/inspectionProfiles/Project_Default.xml | 41 ++ .idea/kotlinc.xml | 6 + .idea/migrations.xml | 10 + .idea/misc.xml | 9 + README.md | 20 + app/.gitignore | 1 + app/build.gradle.kts | 106 +++ app/proguard-android-optimize.txt | 34 + app/proguard-rules.pro | 3 + .../xtimms/tokusho/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 57 ++ app/src/main/java/org/xtimms/tokusho/App.kt | 103 +++ .../org/xtimms/tokusho/CompositionLocals.kt | 41 ++ .../java/org/xtimms/tokusho/MainActivity.kt | 168 +++++ .../java/org/xtimms/tokusho/TokushoModule.kt | 103 +++ .../org/xtimms/tokusho/core/AsyncImageImpl.kt | 51 ++ .../tokusho/core/BottomNavDestination.kt | 72 ++ .../tokusho/core/ModifierCollapsable.kt | 63 ++ .../org/xtimms/tokusho/core/Navigation.kt | 174 +++++ .../xtimms/tokusho/core/base/event/UiEvent.kt | 6 + .../xtimms/tokusho/core/base/state/UiState.kt | 19 + .../core/base/viewmodel/BaseViewModel.kt | 35 + .../org/xtimms/tokusho/core/cache/CacheDir.kt | 8 + .../xtimms/tokusho/core/cache/ContentCache.kt | 27 + .../tokusho/core/cache/ExpiringLruCache.kt | 33 + .../tokusho/core/cache/ExpiringValue.kt | 34 + .../tokusho/core/cache/MemoryContentCache.kt | 70 ++ .../xtimms/tokusho/core/cache/SafeDeferred.kt | 20 + .../tokusho/core/cache/StubContentCache.kt | 22 + .../tokusho/core/components/ActionButton.kt | 40 ++ .../tokusho/core/components/BottomNavBar.kt | 66 ++ .../tokusho/core/components/ExploreButton.kt | 63 ++ .../tokusho/core/components/IconButton.kt | 19 + .../xtimms/tokusho/core/components/Pill.kt | 46 ++ .../tokusho/core/components/PreferenceItem.kt | 602 ++++++++++++++++ .../core/components/ScaffoldWithTopAppBar.kt | 103 +++ .../tokusho/core/components/SettingItem.kt | 72 ++ .../tokusho/core/components/SourceItem.kt | 63 ++ .../org/xtimms/tokusho/core/components/Tab.kt | 42 ++ .../tokusho/core/components/TopAppBar.kt | 218 ++++++ .../tokusho/core/components/icons/Dice.kt | 60 ++ .../xtimms/tokusho/core/database/Tables.kt | 3 + .../tokusho/core/database/TokushoDatabase.kt | 27 + .../core/database/dao/MangaSourcesDao.kt | 29 + .../core/database/entity/MangaSourceEntity.kt | 17 + .../xtimms/tokusho/core/model/ListModel.kt | 10 + .../tokusho/core/model/ListSortOrder.kt | 25 + .../xtimms/tokusho/core/model/MangaSource.kt | 10 + .../tokusho/core/model/ShelfCategory.kt | 17 + .../tokusho/core/motion/MaterialSharedAxis.kt | 79 +++ .../tokusho/core/motion/MotionConstants.kt | 11 + .../tokusho/core/network/HttpClients.kt | 11 + .../tokusho/core/network/NetworkModule.kt | 69 ++ .../core/network/cookies/AndroidCookieJar.kt | 55 ++ .../core/network/cookies/CookieWrapper.kt | 68 ++ .../core/network/cookies/MutableCookieJar.kt | 21 + .../network/cookies/PreferencesCookieJar.kt | 108 +++ .../xtimms/tokusho/core/os/NetworkState.kt | 50 ++ .../core/parser/LocalMangaRepository.kt | 4 + .../core/parser/MangaLoaderContextImpl.kt | 63 ++ .../xtimms/tokusho/core/parser/MangaParser.kt | 9 + .../tokusho/core/parser/MangaRepository.kt | 73 ++ .../core/parser/RemoteMangaRepository.kt | 144 ++++ .../core/parser/favicon/FaviconFetcher.kt | 194 ++++++ .../tokusho/core/parser/favicon/FaviconUri.kt | 8 + .../xtimms/tokusho/core/prefs/AppSettings.kt | 190 +++++ .../tokusho/core/prefs/SourceSettings.kt | 43 ++ .../tokusho/core/screens/EmptyScreen.kt | 112 +++ .../xtimms/tokusho/core/screens/InfoScreen.kt | 142 ++++ .../tokusho/core/screens/UpdateDialog.kt | 94 +++ .../xtimms/tokusho/core/updates/Updater.kt | 299 ++++++++ .../org/xtimms/tokusho/crash/CrashActivity.kt | 41 ++ .../org/xtimms/tokusho/crash/CrashScreen.kt | 71 ++ .../tokusho/crash/GlobalExceptionHandler.kt | 76 ++ .../tokusho/data/LocalStorageManager.kt | 41 ++ .../data/repository/MangaSourcesRepository.kt | 30 + .../tokusho/sections/explore/ExploreEvent.kt | 7 + .../sections/explore/ExploreUiState.kt | 15 + .../tokusho/sections/explore/ExploreView.kt | 188 +++++ .../sections/explore/ExploreViewModel.kt | 36 + .../tokusho/sections/history/HistoryView.kt | 65 ++ .../tokusho/sections/list/MangaListView.kt | 36 + .../tokusho/sections/search/SearchView.kt | 114 +++ .../tokusho/sections/settings/SettingsView.kt | 49 ++ .../sections/settings/about/AboutView.kt | 80 +++ .../sections/settings/about/UpdateView.kt | 197 ++++++ .../settings/appearance/AppearanceView.kt | 277 ++++++++ .../sections/settings/appearance/Card.kt | 88 +++ .../settings/appearance/DarkThemeView.kt | 77 +++ .../settings/appearance/LanguagesView.kt | 200 ++++++ .../tokusho/sections/shelf/ShelfItem.kt | 9 + .../tokusho/sections/shelf/ShelfManga.kt | 24 + .../tokusho/sections/shelf/ShelfPager.kt | 78 +++ .../tokusho/sections/shelf/ShelfTabs.kt | 54 ++ .../tokusho/sections/shelf/ShelfView.kt | 115 +++ .../shelf/ext/ShelfCategoryExtensions.kt | 20 + .../tokusho/ui/harmonize/hct/Cam16.java | 419 +++++++++++ .../tokusho/ui/harmonize/hct/CamSolver.java | 652 ++++++++++++++++++ .../xtimms/tokusho/ui/harmonize/hct/Hct.java | 127 ++++ .../ui/harmonize/hct/ViewingConditions.java | 197 ++++++ .../ui/harmonize/palettes/CorePalette.java | 76 ++ .../ui/harmonize/palettes/TonalPalette.java | 85 +++ .../ui/harmonize/utils/ColorUtils.java | 271 ++++++++ .../tokusho/ui/harmonize/utils/MathUtils.java | 133 ++++ .../org/xtimms/tokusho/ui/monet/ColorSpec.kt | 6 + .../java/org/xtimms/tokusho/ui/monet/Monet.kt | 101 +++ .../xtimms/tokusho/ui/monet/PaletteStyle.kt | 133 ++++ .../xtimms/tokusho/ui/monet/TonalPalettes.kt | 119 ++++ .../java/org/xtimms/tokusho/ui/theme/Color.kt | 36 + .../java/org/xtimms/tokusho/ui/theme/Theme.kt | 89 +++ .../java/org/xtimms/tokusho/ui/theme/Type.kt | 42 ++ .../xtimms/tokusho/utils/CancellableSource.kt | 18 + .../xtimms/tokusho/utils/CoilImageGetter.kt | 29 + .../org/xtimms/tokusho/utils/CrashLogUtil.kt | 42 ++ .../tokusho/utils/ExtraCloseableSource.kt | 21 + .../xtimms/tokusho/utils/MediatorStateFlow.kt | 39 ++ .../java/org/xtimms/tokusho/utils/Modifier.kt | 7 + .../tokusho/utils/ProgressResponseBody.kt | 51 ++ .../xtimms/tokusho/utils/lang/Coroutines.kt | 72 ++ .../java/org/xtimms/tokusho/utils/lang/IO.kt | 25 + .../xtimms/tokusho/utils/lang/Primitive.kt | 22 + .../org/xtimms/tokusho/utils/lang/String.kt | 5 + .../tokusho/utils/material/Constants.kt | 3 + .../org/xtimms/tokusho/utils/storage/File.kt | 16 + .../xtimms/tokusho/utils/system/Context.kt | 23 + .../org/xtimms/tokusho/utils/system/File.kt | 5 + .../org/xtimms/tokusho/utils/system/Http.kt | 23 + .../org/xtimms/tokusho/utils/system/Intent.kt | 30 + .../org/xtimms/tokusho/utils/system/Locale.kt | 46 ++ .../xtimms/tokusho/utils/system/Network.kt | 18 + .../tokusho/utils/system/PaddingValues.kt | 22 + .../tokusho/utils/system/Preferences.kt | 18 + .../org/xtimms/tokusho/utils/system/Toast.kt | 22 + app/src/main/res/drawable-nodpi/ookami.webp | Bin 0 -> 37160 bytes app/src/main/res/drawable-nodpi/sample.webp | Bin 0 -> 18146 bytes app/src/main/res/drawable-nodpi/sample1.webp | Bin 0 -> 53616 bytes .../res/drawable/ic_launcher_background.xml | 170 +++++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 60 ++ app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + app/src/main/res/xml/provider_paths.xml | 9 + .../org/xtimms/tokusho/ExampleUnitTest.kt | 17 + build.gradle.kts | 13 + gradle.properties | 23 + gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 +++++ gradlew.bat | 89 +++ settings.gradle.kts | 19 + 169 files changed, 10605 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetDropDown.xml create mode 100644 .idea/discord.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/kotlinc.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-android-optimize.txt create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/org/xtimms/tokusho/App.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/MainActivity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/TokushoModule.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/Navigation.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Cam16.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/File.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt create mode 100644 app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt create mode 100644 app/src/main/res/drawable-nodpi/ookami.webp create mode 100644 app/src/main/res/drawable-nodpi/sample.webp create mode 100644 app/src/main/res/drawable-nodpi/sample1.webp create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/provider_paths.xml create mode 100644 app/src/test/java/org/xtimms/tokusho/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e1384e --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/jarRepositories.xml +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# End of https://mrkandreev.name/snippets/gitignore-generator/#Kotlin,Android \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..3337158 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Tokusho \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..8184c57 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..d8e9561 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceb5d03 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Tokusho + +An attempt to write an Android manga reading application on Jetpack Compose using the [Kotatsu parser library](https://github.com/KotatsuApp/kotatsu-parsers). + +## Is it possible to use it now? + +No, nothing works. + +## Acknowledgements + +[Kotatsu](https://github.com/KotatsuApp/Kotatsu) - UI and parsers +[Seal](https://github.com/JunkFood02/Seal) - UI + +## License + +You may copy, distribute and modify the software as long as you track changes/dates in source files. +Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the +GPL along with build & install instructions. + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..14fdce7 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,106 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") + id("org.jetbrains.kotlin.kapt") + id("com.google.devtools.ksp") + id("dagger.hilt.android.plugin") +} + +android { + namespace = "org.xtimms.tokusho" + compileSdk = 34 + + defaultConfig { + applicationId = "org.xtimms.tokusho" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + javaCompileOptions { + annotationProcessorOptions { + arguments += mapOf( + "room.generateKotlin" to "true" + ) + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + 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("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-rc01") + implementation("androidx.compose.material3:material3-window-size-class:1.2.0-rc01") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation("androidx.navigation:navigation-compose:2.7.6") + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + implementation("androidx.work:work-runtime-ktx:2.9.0") + ksp("androidx.room:room-compiler:2.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0") + implementation("com.google.accompanist:accompanist-pager:0.32.0") + 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("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.tencent:mmkv:1.3.2") + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") + implementation("io.coil-kt:coil-compose:2.5.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} \ No newline at end of file diff --git a/app/proguard-android-optimize.txt b/app/proguard-android-optimize.txt new file mode 100644 index 0000000..7072ff7 --- /dev/null +++ b/app/proguard-android-optimize.txt @@ -0,0 +1,34 @@ +-dontusemixedcaseclassnames +-ignorewarnings +-verbose + +-keepattributes *Annotation* + +-keepclasseswithmembernames,includedescriptorclasses class * { + native ; +} + +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +-keep class androidx.annotation.Keep + +-keep @androidx.annotation.Keep class * {*;} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; +} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; +} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep (...); +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..72c795e --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-dontobfuscate + +-keep,allowoptimization class org.xtimms.** \ 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 new file mode 100644 index 0000000..b006053 --- /dev/null +++ b/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c1aaf35 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/App.kt b/app/src/main/java/org/xtimms/tokusho/App.kt new file mode 100644 index 0000000..8098da6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/App.kt @@ -0,0 +1,103 @@ +package org.xtimms.tokusho + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.StrictMode +import com.google.android.material.color.DynamicColors +import com.tencent.mmkv.MMKV +import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.xtimms.tokusho.core.database.MangaDatabase +import org.xtimms.tokusho.core.updates.Updater +import org.xtimms.tokusho.crash.CrashActivity +import org.xtimms.tokusho.crash.GlobalExceptionHandler +import javax.inject.Inject +import javax.inject.Provider + +@HiltAndroidApp +class App : Application() { + + @Inject + lateinit var database: Provider + + override fun onCreate() { + super.onCreate() + MMKV.initialize(this) + context = applicationContext + packageInfo = packageManager.run { + if (Build.VERSION.SDK_INT >= 33) getPackageInfo( + packageName, PackageManager.PackageInfoFlags.of(0) + ) else getPackageInfo(packageName, 0) + } + applicationScope = CoroutineScope(SupervisorJob()) + DynamicColors.applyToActivitiesIfAvailable(this) + + applicationScope.launch((Dispatchers.IO)) { + try { + Updater.deleteOutdatedApk() + } catch (_: Throwable) { + + } + } + + GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java) + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + if (BuildConfig.DEBUG) { + enableStrictMode() + } + } + + private fun enableStrictMode() { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .setClassInstanceLimit(MangaLoaderContext::class.java, 1) + .penaltyLog() + .build(), + ) + } + + companion object { + + lateinit var applicationScope: CoroutineScope + lateinit var packageInfo: PackageInfo + + fun getVersionReport(): String { + val versionName = packageInfo.versionName + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + packageInfo.versionCode.toLong() + } + val release = if (Build.VERSION.SDK_INT >= 30) { + Build.VERSION.RELEASE_OR_CODENAME + } else { + Build.VERSION.RELEASE + } + return StringBuilder().append("App version: $versionName ($versionCode)\n") + .append("Device information: Android $release (API ${Build.VERSION.SDK_INT})\n") + .append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n").toString() + } + + @SuppressLint("StaticFieldLeak") + lateinit var context: Context + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt new file mode 100644 index 0000000..eca513e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt @@ -0,0 +1,41 @@ +package org.xtimms.tokusho + +import android.os.Build +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import org.xtimms.shiki.ui.theme.SEED +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.DarkThemePreference +import org.xtimms.tokusho.core.prefs.paletteStyles +import org.xtimms.tokusho.ui.monet.LocalTonalPalettes +import org.xtimms.tokusho.ui.monet.PaletteStyle +import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes + +val LocalDarkTheme = compositionLocalOf { DarkThemePreference() } +val LocalSeedColor = compositionLocalOf { SEED } +val LocalDynamicColorSwitch = compositionLocalOf { false } +val LocalPaletteStyleIndex = compositionLocalOf { 0 } + +@Composable +fun SettingsProvider(content: @Composable () -> Unit) { + AppSettings.AppSettingsStateFlow.collectAsState().value.run { + CompositionLocalProvider( + LocalDarkTheme provides darkTheme, + LocalSeedColor provides seedColor, + LocalPaletteStyleIndex provides paletteStyleIndex, + LocalTonalPalettes provides if (isDynamicColorEnabled && Build.VERSION.SDK_INT >= 31) dynamicDarkColorScheme( + LocalContext.current + ).toTonalPalettes() + else Color(seedColor).toTonalPalettes( + paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot } + ), + LocalDynamicColorSwitch provides isDynamicColorEnabled, + content = content + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt new file mode 100644 index 0000000..bbf205f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt @@ -0,0 +1,168 @@ +package org.xtimms.tokusho + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Scaffold +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +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.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +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.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import coil.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.xtimms.tokusho.core.Navigation +import org.xtimms.tokusho.core.components.BottomNavBar +import org.xtimms.tokusho.core.components.TopAppBar +import org.xtimms.tokusho.ui.theme.TokushoTheme +import javax.inject.Inject + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject lateinit var coil: ImageLoader + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + 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, + isCompactScreen = isCompactScreen, + navController = navController + ) + } + } + } + } + + companion object { + private const val TAG = "MainActivity" + + fun setLanguage(locale: String) { + Log.d(TAG, "setLanguage: $locale") + val localeListCompat = + if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList() + else LocaleListCompat.forLanguageTags(locale) + App.applicationScope.launch(Dispatchers.Main) { + AppCompatDelegate.setApplicationLocales(localeListCompat) + } + } + + } +} + +@Composable +fun MainView( + coil: ImageLoader, + isCompactScreen: Boolean, + navController: NavHostController, +) { + val density = LocalDensity.current + + val bottomBarState = remember { mutableStateOf(true) } + var topBarHeightPx by remember { mutableFloatStateOf(0f) } + val topBarOffsetY = remember { Animatable(0f) } + + Scaffold( + topBar = { + if (isCompactScreen) { + TopAppBar( + navController = navController, + modifier = Modifier + .padding(0.dp, 8.dp) + .graphicsLayer { + translationY = topBarOffsetY.value + } + ) + } + }, + bottomBar = { + if (isCompactScreen) { + BottomNavBar( + navController = navController, + bottomBarState = bottomBarState, + ) + } + }, + contentWindowInsets = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + ) { padding -> + if (!isCompactScreen) { + val systemBarsPadding = WindowInsets.systemBars.asPaddingValues() + Row( + modifier = Modifier.padding(padding) + ) { + Navigation( + coil = coil, + navController = navController, + isCompactScreen = false, + modifier = Modifier, + padding = PaddingValues( + start = padding.calculateStartPadding(LocalLayoutDirection.current), + top = systemBarsPadding.calculateTopPadding(), + end = padding.calculateEndPadding(LocalLayoutDirection.current), + bottom = systemBarsPadding.calculateBottomPadding() + ), + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY + ) + } + } else { + LaunchedEffect(padding) { + topBarHeightPx = density.run { padding.calculateTopPadding().toPx() } + } + Navigation( + coil = coil, + navController = navController, + isCompactScreen = true, + modifier = Modifier.padding( + start = padding.calculateStartPadding(LocalLayoutDirection.current), + end = padding.calculateEndPadding(LocalLayoutDirection.current), + ), + padding = padding, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt new file mode 100644 index 0000000..fcddc2c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt @@ -0,0 +1,103 @@ +package org.xtimms.tokusho + +import android.app.Application +import android.content.Context +import android.text.Html +import coil.ComponentRegistry +import coil.ImageLoader +import coil.disk.DiskCache +import coil.util.DebugLogger +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.xtimms.tokusho.core.cache.CacheDir +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.MangaDatabase +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.utils.CoilImageGetter +import org.xtimms.tokusho.utils.system.connectivityManager +import org.xtimms.tokusho.utils.system.isLowRamDevice +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface TokushoModule { + + @Binds + fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext + + @Binds + fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter + + companion object { + + @Provides + @Singleton + fun provideNetworkState( + @ApplicationContext context: Context + ) = NetworkState(context.connectivityManager) + + @Provides + @Singleton + fun provideMangaDatabase( + @ApplicationContext context: Context, + ): MangaDatabase { + return MangaDatabase(context) + } + + @Provides + @Singleton + fun provideCoil( + @ApplicationContext context: Context, + @MangaHttpClient okHttpClient: OkHttpClient, + mangaRepositoryFactory: MangaRepository.Factory, + ): ImageLoader { + val diskCacheFactory = { + val rootDir = context.externalCacheDir ?: context.cacheDir + DiskCache.Builder() + .directory(rootDir.resolve(CacheDir.THUMBS.dir)) + .build() + } + return ImageLoader.Builder(context) + .crossfade(500) + .okHttpClient(okHttpClient.newBuilder().cache(null).build()) + .interceptorDispatcher(Dispatchers.Default) + .fetcherDispatcher(Dispatchers.IO) + .decoderDispatcher(Dispatchers.Default) + .transformationDispatcher(Dispatchers.Default) + .diskCache(diskCacheFactory) + .logger(if (BuildConfig.DEBUG) DebugLogger() else null) + .components( + ComponentRegistry.Builder() + .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) + .build(), + ).build() + } + + @Provides + @Singleton + fun provideContentCache( + application: Application, + ): ContentCache { + return if (application.isLowRamDevice()) { + StubContentCache() + } else { + MemoryContentCache(application) + } + } + + } + +} \ 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 new file mode 100644 index 0000000..599fe13 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt @@ -0,0 +1,51 @@ +package org.xtimms.tokusho.core + +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.ColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope +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 + +@Composable +fun AsyncImageImpl( + coil: ImageLoader, + model: Any? = null, + contentDescription: String?, + modifier: Modifier = Modifier, + transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, + onState: ((AsyncImagePainter.State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Crop, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, + isPreview: Boolean = false, +) { + if (isPreview) Image( + painter = painterResource(R.drawable.sample), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + else AsyncImage( + imageLoader = coil, + model = model?.takeUnless { it == "" }, + contentDescription = contentDescription, + modifier = modifier, + transform = transform, + onState = onState, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + filterQuality = filterQuality + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt b/app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt new file mode 100644 index 0000000..838d2f5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt @@ -0,0 +1,72 @@ +package org.xtimms.tokusho.core + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.LocalLibrary +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.LocalLibrary +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import org.xtimms.tokusho.R +import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION +import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION +import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION + +sealed class BottomNavDestination( + val value: String, + val route: String, + @StringRes val title: Int, + val icon: ImageVector, + val iconSelected: ImageVector, +) { + data object Shelf : BottomNavDestination( + value = "shelf", + route = SHELF_DESTINATION, + title = R.string.nav_shelf, + icon = Icons.Outlined.LocalLibrary, + iconSelected = Icons.Filled.LocalLibrary + ) + + data object History : BottomNavDestination( + value = "history", + route = HISTORY_DESTINATION, + title = R.string.nav_history, + icon = Icons.Outlined.History, + iconSelected = Icons.Filled.History + ) + + data object Explore : BottomNavDestination( + value = "explore", + route = EXPLORE_DESTINATION, + title = R.string.nav_explore, + icon = Icons.Outlined.Explore, + iconSelected = Icons.Filled.Explore + ) + + companion object { + val values = listOf(Shelf, History, Explore) + + val railValues = listOf(Shelf, History, Explore) + + val routes = values.map { it.route } + + fun String.toBottomDestinationIndex() = when (this) { + Shelf.value -> 0 + History.value -> 1 + Explore.value -> 2 + else -> null + } + + @Composable + fun BottomNavDestination.Icon(selected: Boolean) { + androidx.compose.material3.Icon( + imageVector = if (selected) iconSelected else icon, + contentDescription = stringResource(title) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt b/app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt new file mode 100644 index 0000000..efc5702 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.core + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +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 kotlinx.coroutines.launch +import kotlin.math.abs + +fun Modifier.collapsable( + state: ScrollableState, + topBarHeightPx: Float, + topBarOffsetY: Animatable, +) = composed { + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = state.isScrollInProgress) { + if (!state.isScrollInProgress && topBarOffsetY.value != 0f && topBarOffsetY.value != -topBarHeightPx) { + val half = topBarHeightPx / 2 + val oldOffsetY = topBarOffsetY.value + + val targetOffsetY = when { + abs(topBarOffsetY.value) >= half -> -topBarHeightPx + else -> 0f + } + + launch { + state.animateScrollBy(oldOffsetY - targetOffsetY) + } + + launch { + topBarOffsetY.animateTo(targetOffsetY) + } + } + } + + nestedScroll( + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + scope.launch { + if (state.canScrollForward) { + topBarOffsetY.snapTo( + targetValue = (topBarOffsetY.value + available.y).coerceIn( + minimumValue = -topBarHeightPx, + maximumValue = 0f, + ) + ) + } + } + + return Offset.Zero + } + } + ) +} \ 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 new file mode 100644 index 0000000..c8dffc7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -0,0 +1,174 @@ +package org.xtimms.tokusho.core + +import android.graphics.Path +import android.view.animation.PathInterpolator +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import coil.ImageLoader +import org.xtimms.tokusho.core.model.ShelfCategory +import org.xtimms.tokusho.core.motion.materialSharedAxisXIn +import org.xtimms.tokusho.core.motion.materialSharedAxisXOut +import org.xtimms.tokusho.sections.explore.ExploreView +import org.xtimms.tokusho.sections.history.HistoryView +import org.xtimms.tokusho.sections.list.LIST_DESTINATION +import org.xtimms.tokusho.sections.list.MangaListView +import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION +import org.xtimms.tokusho.sections.search.SearchHostView +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.UPDATES_DESTINATION +import org.xtimms.tokusho.sections.settings.about.UpdateView +import org.xtimms.tokusho.sections.settings.appearance.APPEARANCE_DESTINATION +import org.xtimms.tokusho.sections.settings.appearance.AppearanceView +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.shelf.ShelfMap +import org.xtimms.tokusho.sections.shelf.ShelfView + +const val DURATION_ENTER = 400 +const val DURATION_EXIT = 200 +const val initialOffset = 0.10f + +fun PathInterpolator.toEasing(): Easing { + return Easing { f -> this.getInterpolation(f) } +} + +@Composable +fun Navigation( + coil: ImageLoader, + navController: NavHostController, + isCompactScreen: Boolean, + modifier: Modifier, + padding: PaddingValues, + topBarHeightPx: Float, + topBarOffsetY: Animatable, +) { + + val navigateBack: () -> Unit = { navController.popBackStack() } + + val path = Path().apply { + moveTo(0f, 0f) + cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) + cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F) + } + + val emphasizePathInterpolator = PathInterpolator(path) + val emphasizeEasing = emphasizePathInterpolator.toEasing() + + val enterTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing) + val exitTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing) + val fadeTween = tween(durationMillis = DURATION_EXIT) + val fadeSpec = fadeTween + + NavHost( + navController = navController, + startDestination = BottomNavDestination.Shelf.route, + modifier = modifier, + enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * initialOffset).toInt() }) }, + exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) }, + popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }) }, + popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * initialOffset).toInt() }) } + ) { + + composable(BottomNavDestination.Shelf.route) { + val library: ShelfMap = emptyMap() + ShelfView( + categories = listOf(ShelfCategory(1, "Test 1", 1L, 1L), ShelfCategory(2, "Test 2", 2L, 2L)), + currentPage = { 0 }, + showPageTabs = true, + getNumberOfMangaForCategory = { 2 }, + getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() }, + padding = padding, + topBarHeightPx = topBarHeightPx, + ) + } + + composable(BottomNavDestination.History.route) { + HistoryView( + padding = padding, + topBarHeightPx = topBarHeightPx, + ) + } + + composable(BottomNavDestination.Explore.route) { + ExploreView( + coil = coil, + navController = navController, + padding = padding, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY + ) + } + + composable(SEARCH_DESTINATION) { + SearchHostView( + isCompactScreen = isCompactScreen, + padding = if (isCompactScreen) PaddingValues() else padding, + navigateBack = navigateBack, + ) + } + + composable(SETTINGS_DESTINATION) { + SettingsView( + navigateBack = navigateBack, + navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) }, + navigateToAbout = { navController.navigate(ABOUT_DESTINATION) } + ) + } + + composable(APPEARANCE_DESTINATION) { + AppearanceView( + coil = coil, + navigateBack = navigateBack, + navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) }, + navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) } + ) + } + + composable(DARK_THEME_DESTINATION) { + DarkThemeView( + navigateBack = navigateBack + ) + } + + composable(LANGUAGES_DESTINATION) { + LanguagesView( + navigateBack = navigateBack + ) + } + + composable(LIST_DESTINATION) { + MangaListView( + sourceName = "Source", + navigateBack = navigateBack, + ) + } + + composable(ABOUT_DESTINATION) { + AboutView( + navigateBack = navigateBack, + navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) } + ) + } + + composable(UPDATES_DESTINATION) { + UpdateView( + navigateBack = navigateBack, + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt b/app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt new file mode 100644 index 0000000..56444c4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt @@ -0,0 +1,6 @@ +package org.xtimms.tokusho.core.base.event + +interface UiEvent { + fun showMessage(message: String?) + fun onMessageDisplayed() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt b/app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt new file mode 100644 index 0000000..d909208 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt @@ -0,0 +1,19 @@ +package org.xtimms.tokusho.core.base.state + +abstract class UiState { + abstract val isLoading: Boolean + abstract val message: String? + + // These methods are required because we can't have an abstract data class + // so we need to manually implement the copy() method + + /** + * copy(isLoading = value) + */ + abstract fun setLoading(value: Boolean): UiState + + /** + * copy(message = value) + */ + abstract fun setMessage(value: String?): UiState +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..1759076 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt @@ -0,0 +1,35 @@ +package org.xtimms.tokusho.core.base.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.xtimms.tokusho.core.base.event.UiEvent +import org.xtimms.tokusho.core.base.state.UiState + +abstract class BaseViewModel : ViewModel(), UiEvent { + + protected abstract val mutableUiState: MutableStateFlow + val uiState: StateFlow by lazy { mutableUiState.asStateFlow() } + + @Suppress("UNCHECKED_CAST") + fun setLoading(value: Boolean) { + mutableUiState.update { it.setLoading(value) as S } + } + + @Suppress("UNCHECKED_CAST") + override fun showMessage(message: String?) { + mutableUiState.update { it.setMessage(message ?: GENERIC_ERROR) as S } + } + + @Suppress("UNCHECKED_CAST") + override fun onMessageDisplayed() { + mutableUiState.update { it.setMessage(null) as S } + } + + companion object { + private const val GENERIC_ERROR = "Generic Error" + const val FLOW_TIMEOUT = 5_000L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt new file mode 100644 index 0000000..49223c5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.core.cache + +enum class CacheDir(val dir: String) { + + THUMBS("image_cache"), + FAVICONS("favicons"), + PAGES("pages"); +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt new file mode 100644 index 0000000..14d8faf --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt @@ -0,0 +1,27 @@ +package org.xtimms.tokusho.core.cache + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource + +interface ContentCache { + + val isCachingEnabled: Boolean + + suspend fun getDetails(source: MangaSource, url: String): Manga? + + fun putDetails(source: MangaSource, url: String, details: SafeDeferred) + + suspend fun getPages(source: MangaSource, url: String): List? + + fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) + + suspend fun getRelatedManga(source: MangaSource, url: String): List? + + fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) + + data class Key( + val source: MangaSource, + val url: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt new file mode 100644 index 0000000..6a3867e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt @@ -0,0 +1,33 @@ +package org.xtimms.tokusho.core.cache + +import androidx.collection.LruCache +import java.util.concurrent.TimeUnit + +class ExpiringLruCache( + val maxSize: Int, + private val lifetime: Long, + private val timeUnit: TimeUnit, +) { + + private val cache = LruCache>(maxSize) + + operator fun get(key: ContentCache.Key): T? { + val value = cache[key] ?: return null + if (value.isExpired) { + cache.remove(key) + } + return value.get() + } + + operator fun set(key: ContentCache.Key, value: T) { + cache.put(key, ExpiringValue(value, lifetime, timeUnit)) + } + + fun clear() { + cache.evictAll() + } + + fun trimToSize(size: Int) { + cache.trimToSize(size) + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt new file mode 100644 index 0000000..5d14b5e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt @@ -0,0 +1,34 @@ +package org.xtimms.tokusho.core.cache + +import android.os.SystemClock +import java.util.concurrent.TimeUnit + +class ExpiringValue( + private val value: T, + lifetime: Long, + timeUnit: TimeUnit, +) { + + private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime) + + val isExpired: Boolean + get() = SystemClock.elapsedRealtime() >= expiresAt + + fun get(): T? = if (isExpired) null else value + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ExpiringValue<*> + + if (value != other.value) return false + return expiresAt == other.expiresAt + } + + override fun hashCode(): Int { + var result = value?.hashCode() ?: 0 + result = 31 * result + expiresAt.hashCode() + return result + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt new file mode 100644 index 0000000..bb0b83a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt @@ -0,0 +1,70 @@ +package org.xtimms.tokusho.core.cache + +import android.app.Application +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import java.util.concurrent.TimeUnit + +class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 { + + init { + application.registerComponentCallbacks(this) + } + + private val detailsCache = ExpiringLruCache>(4, 5, TimeUnit.MINUTES) + private val pagesCache = ExpiringLruCache>>(4, 10, TimeUnit.MINUTES) + private val relatedMangaCache = ExpiringLruCache>>(4, 10, TimeUnit.MINUTES) + + override val isCachingEnabled: Boolean = true + + override suspend fun getDetails(source: MangaSource, url: String): Manga? { + return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull() + } + + override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) { + detailsCache[ContentCache.Key(source, url)] = details + } + + override suspend fun getPages(source: MangaSource, url: String): List? { + return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull() + } + + override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) { + pagesCache[ContentCache.Key(source, url)] = pages + } + + override suspend fun getRelatedManga(source: MangaSource, url: String): List? { + return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull() + } + + override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) { + relatedMangaCache[ContentCache.Key(source, url)] = related + } + + override fun onConfigurationChanged(newConfig: Configuration) = Unit + + override fun onLowMemory() = Unit + + override fun onTrimMemory(level: Int) { + trimCache(detailsCache, level) + trimCache(pagesCache, level) + trimCache(relatedMangaCache, level) + } + + private fun trimCache(cache: ExpiringLruCache<*>, level: Int) { + when (level) { + ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + ComponentCallbacks2.TRIM_MEMORY_COMPLETE, + ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear() + + ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, + ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1) + + else -> cache.trimToSize(cache.maxSize / 2) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt new file mode 100644 index 0000000..fdaffa4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt @@ -0,0 +1,20 @@ +package org.xtimms.tokusho.core.cache + +import kotlinx.coroutines.Deferred + +class SafeDeferred( + private val delegate: Deferred>, +) { + + suspend fun await(): T { + return delegate.await().getOrThrow() + } + + suspend fun awaitOrNull(): T? { + return delegate.await().getOrNull() + } + + fun cancel() { + delegate.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt new file mode 100644 index 0000000..1deb47c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.core.cache + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource + +class StubContentCache : ContentCache { + + override val isCachingEnabled: Boolean = false + + override suspend fun getDetails(source: MangaSource, url: String): Manga? = null + + override fun putDetails(source: MangaSource, url: String, details: SafeDeferred) = Unit + + override suspend fun getPages(source: MangaSource, url: String): List? = null + + override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) = Unit + + override suspend fun getRelatedManga(source: MangaSource, url: String): List? = null + + override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt new file mode 100644 index 0000000..a83b07d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt @@ -0,0 +1,40 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ActionButton( + title: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextButton( + modifier = modifier, + onClick = onClick, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + Text( + text = title, + textAlign = TextAlign.Center, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt new file mode 100644 index 0000000..88d15fa --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt @@ -0,0 +1,66 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import org.xtimms.tokusho.core.BottomNavDestination +import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon +import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION +import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION +import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION + +@Composable +fun BottomNavBar( + navController: NavController, + bottomBarState: State, +) { + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val isVisible by remember { + derivedStateOf { + when (navBackStackEntry?.destination?.route) { + SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, null -> bottomBarState.value + + else -> false + } + } + } + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }) + ) { + NavigationBar { + BottomNavDestination.values.forEachIndexed { _, dest -> + val isSelected = navBackStackEntry?.destination?.route == dest.route + NavigationBarItem( + selected = isSelected, + onClick = { + navController.navigate(dest.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { dest.Icon(selected = isSelected) }, + label = { Text(text = stringResource(dest.title)) } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt new file mode 100644 index 0000000..1150744 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExploreButton( + text: String, + icon: ImageVector, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = modifier.padding(start = 8.dp, end = 8.dp), + shape = RoundedCornerShape(50), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + ) + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = text, + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + lineHeight = 15.sp + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt new file mode 100644 index 0000000..8c54853 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt @@ -0,0 +1,19 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable + +@Composable +fun BackIconButton( + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "arrow_back" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt b/app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt new file mode 100644 index 0000000..3e80048 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt @@ -0,0 +1,46 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp + +@Composable +fun Pill( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.background, + contentColor: Color = MaterialTheme.colorScheme.onBackground, + elevation: Dp = 1.dp, + fontSize: TextUnit = LocalTextStyle.current.fontSize, +) { + Surface( + modifier = modifier + .padding(start = 4.dp), + shape = MaterialTheme.shapes.extraLarge, + color = color, + contentColor = contentColor, + tonalElevation = elevation, + ) { + Box( + modifier = Modifier + .padding(6.dp, 1.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + fontSize = fontSize, + maxLines = 1, + ) + } + } +} \ 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 new file mode 100644 index 0000000..a2ae13a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt @@ -0,0 +1,602 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.TipsAndUpdates +import androidx.compose.material.icons.outlined.Call +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.ToggleOn +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material.icons.outlined.Update +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +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.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.applyOpacity +import org.xtimms.tokusho.ui.theme.preferenceTitle + +private const val horizontal = 8 +private const val vertical = 16 + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PreferenceItem( + title: String, + description: String? = null, + icon: Any? = null, + enabled: Boolean = true, + onLongClickLabel: String? = null, + onLongClick: (() -> Unit)? = null, + onClickLabel: String? = null, + leadingIcon: (@Composable () -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + onClick: () -> Unit = {}, +) { + Surface( + modifier = Modifier.combinedClickable( + onClick = onClick, + onClickLabel = onClickLabel, + enabled = enabled, + onLongClickLabel = onLongClickLabel, + onLongClick = onLongClick + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal.dp, vertical.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingIcon?.invoke() + + when (icon) { + is ImageVector -> { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled) + ) + } + + is Painter -> { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled) + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .padding(end = 8.dp) + ) { + PreferenceItemTitle(text = title, enabled = enabled) + if (!description.isNullOrEmpty()) PreferenceItemDescription( + text = description, + enabled = enabled + ) + } + trailingIcon?.let { + VerticalDivider( + modifier = Modifier + .height(32.dp) + .padding(horizontal = 8.dp) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + thickness = 1.dp + ) + trailingIcon.invoke() + } + } + } +} + +@Composable +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 +) { + Text( + modifier = modifier, + text = text, + maxLines = maxLines, + style = style, + color = color.applyOpacity(enabled), + overflow = overflow + ) +} + +@Composable +internal fun PreferenceItemDescription( + modifier: Modifier = Modifier, + text: String, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = MaterialTheme.typography.bodyMedium, + enabled: Boolean, + color: Color = MaterialTheme.colorScheme.onSurfaceVariant, + overflow: TextOverflow = TextOverflow.Ellipsis +) { + Text( + modifier = modifier.padding(top = 2.dp), + text = text, + maxLines = maxLines, + style = style, + color = color.applyOpacity(enabled), + overflow = overflow + ) +} + +@Composable +fun PreferenceSwitchWithDivider( + title: String, + description: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + isSwitchEnabled: Boolean = enabled, + isChecked: Boolean = true, + checkedIcon: ImageVector = Icons.Outlined.Check, + onClick: (() -> Unit) = {}, + onChecked: () -> Unit = {} +) { + val thumbContent: (@Composable () -> Unit)? = if (isChecked) { + { + Icon( + imageVector = checkedIcon, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } else { + null + } + Surface( + modifier = Modifier.clickable( + enabled = enabled, + onClick = onClick, + onClickLabel = stringResource(id = R.string.open_settings) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal.dp, vertical.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled) + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + PreferenceItemTitle(text = title, enabled = enabled) + if (!description.isNullOrEmpty()) PreferenceItemDescription( + text = description, + enabled = enabled + ) + } + VerticalDivider( + modifier = Modifier + .height(32.dp) + .padding(horizontal = 8.dp) + .width(1f.dp) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + Switch( + checked = isChecked, + onCheckedChange = { onChecked() }, + modifier = Modifier + .padding(horizontal = 6.dp) + .semantics { + contentDescription = title + }, + enabled = isSwitchEnabled, + thumbContent = thumbContent + ) + } + } +} + +@Composable +fun PreferenceSwitchWithContainer( + title: String, + icon: ImageVector? = null, + isChecked: Boolean, + onClick: () -> Unit, +) { + val thumbContent: (@Composable () -> Unit)? = if (isChecked) { + { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } else { + null + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background( + if (isChecked) FixedAccentColors.primaryFixed else MaterialTheme.colorScheme.outline + ) + .toggleable(value = isChecked) { onClick() } + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = if (isChecked) FixedAccentColors.onPrimaryFixed else MaterialTheme.colorScheme.surface + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) + ) { + with(MaterialTheme) { + Text( + text = title, + maxLines = 2, + style = preferenceTitle, + color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface + ) + } + } + Switch( + checked = isChecked, + onCheckedChange = null, + modifier = Modifier.padding(start = 12.dp, end = 6.dp), + thumbContent = thumbContent, + colors = SwitchDefaults.colors( + checkedIconColor = FixedAccentColors.onPrimaryFixed, + checkedThumbColor = FixedAccentColors.primaryFixed, + checkedTrackColor = FixedAccentColors.onPrimaryFixedVariant, + uncheckedBorderColor = Color.Transparent + ) + ) + } +} + +@Composable +fun PreferenceSubtitle( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(start = 18.dp, top = 24.dp, bottom = 12.dp), + text: String, + color: Color = MaterialTheme.colorScheme.primary, +) { + Text( + text = text, + modifier = modifier + .fillMaxWidth() + .padding(contentPadding), + color = color, + style = MaterialTheme.typography.labelLarge + ) +} + +@Composable +fun PreferenceSingleChoiceItem( + modifier: Modifier = Modifier, + text: String, + selected: Boolean, + contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 18.dp), + onClick: () -> Unit +) { + Surface( + modifier = Modifier.selectable( + selected = selected, onClick = onClick + ) + ) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(contentPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 10.dp) + ) { + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp), + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis + ) + } + RadioButton( + selected = selected, + onClick = onClick, + modifier = Modifier + .padding() + .clearAndSetSemantics { }, + ) + } + } +} + +@Composable +fun PreferenceInfo( + modifier: Modifier = Modifier, + text: String, + icon: ImageVector = Icons.Outlined.Info, + applyPaddings: Boolean = true +) { + Row(modifier = modifier + .fillMaxWidth() + .run { + if (applyPaddings) padding(horizontal = 16.dp, vertical = 16.dp) + else this + }) { + Icon( + modifier = Modifier.padding(), imageVector = icon, contentDescription = null + ) + Text( + modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.CenterVertically), + text = text, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +fun PreferenceSwitch( + title: String, + description: String? = null, + icon: ImageVector? = null, + enabled: Boolean = true, + isChecked: Boolean = true, + checkedIcon: ImageVector = Icons.Outlined.Check, + onClick: (() -> Unit) = {}, +) { + val thumbContent: (@Composable () -> Unit)? = if (isChecked) { + { + Icon( + imageVector = checkedIcon, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + } else { + null + } + Surface( + modifier = Modifier.toggleable(value = isChecked, + enabled = enabled, + onValueChange = { onClick() }) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal.dp, vertical.dp) + .padding(start = if (icon == null) 12.dp else 0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled) + ) + } + Column( + modifier = Modifier.weight(1f) + .padding(horizontal = 16.dp) + ) { + PreferenceItemTitle( + text = title, enabled = enabled + ) + if (!description.isNullOrEmpty()) PreferenceItemDescription( + text = description, enabled = enabled + ) + } + Switch( + checked = isChecked, + onCheckedChange = null, + modifier = Modifier.padding(start = 20.dp, end = 6.dp), + enabled = enabled, + thumbContent = thumbContent + ) + } + } +} + +@Composable +fun PreferencesHintCard( + title: String = "Title ".repeat(2), + description: String? = "Description text ".repeat(3), + icon: ImageVector? = Icons.Outlined.Translate, + containerColor: Color = FixedAccentColors.secondaryFixed, + contentColor: Color = FixedAccentColors.onSecondaryFixed, + onClick: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background(containerColor) + .clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = contentColor + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) + ) { + with(MaterialTheme) { + Text( + text = title, + maxLines = 1, + style = typography.titleLarge.copy(fontSize = 20.sp), + color = contentColor + ) + if (description != null) Text( + text = description, + color = contentColor, + maxLines = 2, overflow = TextOverflow.Ellipsis, + style = typography.bodyMedium, + ) + } + } + } +} + +@Composable +@Preview +fun PreferenceItemPreview() { + Column { + PreferenceItem(title = "title", description = "description", icon = 0) + PreferenceItem(title = "title", description = "description", icon = Icons.Outlined.Update) + } +} + +@Composable +@Preview +fun PreferenceSwitchPreview() { + PreferenceSwitch( + title = "PreferenceSwitch", + description = "Supporting text", + icon = Icons.Outlined.ToggleOn, + ) +} + +@Composable +@Preview +fun PreferenceSwitchWithDividerPreview() { + PreferenceSwitchWithDivider( + title = "PreferenceSwitch", + description = "Supporting text", + icon = Icons.Outlined.Call, + ) +} + +@Composable +@Preview +private fun PreferenceSwitchWithContainerPreview() { + var isChecked by remember { mutableStateOf(true) } + PreviewThemeLight { + PreferenceSwitchWithContainer( + title = "Title ".repeat(2), + isChecked = isChecked, + onClick = { isChecked = !isChecked }, + icon = null + ) + } +} + +@Composable +@Preview(showBackground = true) +fun PreferenceInfoPreview() { + PreferenceInfo(text = "Title") +} + +@Preview +@Composable +fun PreferencesHintCardPreview() { + CompositionLocalProvider(LocalTonalPalettes provides Color.Green.toTonalPalettes()) { + PreferencesHintCard( + title = "Explore new features", + icon = Icons.Outlined.TipsAndUpdates, + description = "Find out what's new in this version", + containerColor = FixedAccentColors.primaryFixed, + contentColor = FixedAccentColors.onPrimaryFixed, + ) + } +} \ 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 new file mode 100644 index 0000000..9321d77 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt @@ -0,0 +1,103 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScaffoldWithTopAppBar( + title: String, + navigateBack: () -> Unit, + floatingActionButton: @Composable (() -> Unit) = {}, + contentWindowInsets: WindowInsets = WindowInsets.systemBars, + content: @Composable (PaddingValues) -> Unit +) { + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + canScroll = { true } + ) + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + topBar = { + DefaultTopAppBar( + title = title, + scrollBehavior = topAppBarScrollBehavior, + navigateBack = navigateBack + ) + }, + floatingActionButton = floatingActionButton, + contentWindowInsets = contentWindowInsets, + content = content + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScaffoldWithSmallTopAppBar( + title: String, + navigateBack: () -> Unit, + floatingActionButton: @Composable (() -> Unit) = {}, + contentWindowInsets: WindowInsets = WindowInsets.systemBars, + content: @Composable (PaddingValues) -> Unit +) { + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + canScroll = { true } + ) + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + topBar = { + SmallTopAppBar( + title = title, + scrollBehavior = topAppBarScrollBehavior, + navigateBack = navigateBack + ) + }, + floatingActionButton = floatingActionButton, + contentWindowInsets = contentWindowInsets, + content = content + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScaffoldWithClassicTopAppBar( + title: String, + navigateBack: () -> Unit, + floatingActionButton: @Composable (() -> Unit) = {}, + contentWindowInsets: WindowInsets = WindowInsets.systemBars, + content: @Composable (PaddingValues) -> Unit +) { + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + canScroll = { true } + ) + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + topBar = { + ClassicTopAppBar( + title = title, + scrollBehavior = topAppBarScrollBehavior, + navigateBack = navigateBack + ) + }, + floatingActionButton = floatingActionButton, + contentWindowInsets = contentWindowInsets, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt new file mode 100644 index 0000000..b9b7ee2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt @@ -0,0 +1,72 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.ui.theme.applyOpacity + +@Composable +fun SettingTitle(text: String) { + Text( + modifier = Modifier + .padding(top = 32.dp) + .padding(horizontal = 20.dp, vertical = 16.dp), + text = text, + style = MaterialTheme.typography.displaySmall + ) +} + +@Composable +fun SettingItem(title: String, description: String, icon: ImageVector?, onClick: () -> Unit) { + Surface( + modifier = Modifier.clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp, 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true) + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = title, + maxLines = 1, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt new file mode 100644 index 0000000..ef93d49 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.core.components + +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.ImageLoader +import org.xtimms.tokusho.core.AsyncImageImpl + +@Composable +fun SourceItem( + coil: ImageLoader, + faviconUrl: Uri, + title: String, + modifier: Modifier = Modifier, + maxLines: Int = 1, + onClick: () -> Unit, +) { + Column( + modifier = modifier + .width(96.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, end = 8.dp), + horizontalAlignment = Alignment.Start + ) { + AsyncImageImpl( + coil = coil, + model = faviconUrl, + contentDescription = "favicon", + contentScale = ContentScale.Crop, + modifier = modifier + .size(96.dp) + .clip(RoundedCornerShape(8.dp)) + .aspectRatio(1f) + ) + Text( + text = title, + modifier = Modifier + .padding(top = 4.dp, bottom = 4.dp) + .align(Alignment.CenterHorizontally), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + lineHeight = 18.sp, + overflow = TextOverflow.Ellipsis, + maxLines = maxLines + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt b/app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt new file mode 100644 index 0000000..69046dd --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp + +@Composable +fun TabText(text: String, badgeCount: Int? = null) { + val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (badgeCount != null) { + Pill( + text = "$badgeCount", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha), + fontSize = 10.sp, + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun TabTextPreview() { + TabText( + text = "Title", + badgeCount = 5 + ) +} \ 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 new file mode 100644 index 0000000..7cc4558 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt @@ -0,0 +1,218 @@ +package org.xtimms.tokusho.core.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.RssFeed +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.dp +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +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.history.HISTORY_DESTINATION +import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION +import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION +import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION +import org.xtimms.tokusho.ui.theme.TokushoTheme + +@Composable +fun TopAppBar( + navController: NavController, + modifier: Modifier = Modifier, +) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val isVisible by remember { + derivedStateOf { + when (navBackStackEntry?.destination?.route) { + SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, + null -> true + + else -> false + } + } + } + + AnimatedVisibility( + visible = isVisible, + enter = materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }), + exit = materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) + ) { + Row( + modifier = modifier + .statusBarsPadding() + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Card( + onClick = { navController.navigate(SEARCH_DESTINATION) }, + modifier = modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(50), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + ) { + Row( + modifier = modifier + .padding(horizontal = 16.dp) + .fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "search", + tint = MaterialTheme.colorScheme.outline + ) + Text( + text = stringResource(R.string.search), + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Row( + modifier = modifier, + ) { + IconButton( + onClick = { }, + modifier = modifier.padding(0.dp), + ) { + Icon( + Icons.Outlined.RssFeed, + contentDescription = stringResource(id = R.string.feed), + tint = MaterialTheme.colorScheme.outline + ) + } + IconButton( + onClick = { navController.navigate(SETTINGS_DESTINATION) }, + modifier = modifier.padding(0.dp), + ) { + Icon( + Icons.Outlined.Settings, + contentDescription = stringResource(id = R.string.settings), + tint = MaterialTheme.colorScheme.outline + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DefaultTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior? = null, + navigateBack: () -> Unit, +) { + LargeTopAppBar( + title = { Text(text = title) }, + navigationIcon = { + BackIconButton(onClick = navigateBack) + }, + scrollBehavior = scrollBehavior + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SmallTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior? = null, + navigateBack: () -> Unit, +) { + MediumTopAppBar( + title = { Text(text = title) }, + navigationIcon = { + BackIconButton(onClick = navigateBack) + }, + scrollBehavior = scrollBehavior + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClassicTopAppBar( + title: String, + scrollBehavior: TopAppBarScrollBehavior? = null, + navigateBack: () -> Unit, +) { + androidx.compose.material3.TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + BackIconButton(onClick = navigateBack) + }, + scrollBehavior = scrollBehavior + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun DefaultTopAppBarPreview() { + TokushoTheme { + DefaultTopAppBar( + title = "Tokusho", + navigateBack = {} + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun SmallTopAppBarPreview() { + TokushoTheme { + SmallTopAppBar( + title = "Tokusho", + navigateBack = {} + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun ClassicTopAppBarPreview() { + TokushoTheme { + ClassicTopAppBar( + title = "Tokusho", + navigateBack = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt new file mode 100644 index 0000000..7533a40 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt @@ -0,0 +1,60 @@ +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.Dice: ImageVector + get() { + if (_dice != null) { + return _dice!! + } + _dice = materialIcon(name = "Outlined.Dice") { + materialPath { + moveTo(19.0f, 5.0f) + verticalLineTo(19.0f) + horizontalLineTo(5.0f) + verticalLineTo(5.0f) + horizontalLineTo(19.0f) + moveTo(19.0f, 3.0f) + horizontalLineTo(5.0f) + curveTo(3.9f, 3.0f, 3.0f, 3.9f, 3.0f, 5.0f) + verticalLineTo(19.0f) + curveTo(3.0f, 20.1f, 3.9f, 21.0f, 5.0f, 21.0f) + horizontalLineTo(19.0f) + curveTo(20.1f, 21.0f, 21.0f, 20.1f, 21.0f, 19.0f) + verticalLineTo(5.0f) + curveTo(21.0f, 3.9f, 20.1f, 3.0f, 19.0f, 3.0f) + moveTo(7.5f, 6.0f) + curveTo(6.7f, 6.0f, 6.0f, 6.7f, 6.0f, 7.5f) + reflectiveCurveTo(6.7f, 9.0f, 7.5f, 9.0f) + reflectiveCurveTo(9.0f, 8.3f, 9.0f, 7.5f) + reflectiveCurveTo(8.3f, 6.0f, 7.5f, 6.0f) + moveTo(16.5f, 15.0f) + curveTo(15.7f, 15.0f, 15.0f, 15.7f, 15.0f, 16.5f) + curveTo(15.0f, 17.3f, 15.7f, 18.0f, 16.5f, 18.0f) + curveTo(17.3f, 18.0f, 18.0f, 17.3f, 18.0f, 16.5f) + curveTo(18.0f, 15.7f, 17.3f, 15.0f, 16.5f, 15.0f) + moveTo(16.5f, 6.0f) + curveTo(15.7f, 6.0f, 15.0f, 6.7f, 15.0f, 7.5f) + reflectiveCurveTo(15.7f, 9.0f, 16.5f, 9.0f) + curveTo(17.3f, 9.0f, 18.0f, 8.3f, 18.0f, 7.5f) + reflectiveCurveTo(17.3f, 6.0f, 16.5f, 6.0f) + moveTo(12.0f, 10.5f) + curveTo(11.2f, 10.5f, 10.5f, 11.2f, 10.5f, 12.0f) + reflectiveCurveTo(11.2f, 13.5f, 12.0f, 13.5f) + reflectiveCurveTo(13.5f, 12.8f, 13.5f, 12.0f) + reflectiveCurveTo(12.8f, 10.5f, 12.0f, 10.5f) + moveTo(7.5f, 15.0f) + curveTo(6.7f, 15.0f, 6.0f, 15.7f, 6.0f, 16.5f) + curveTo(6.0f, 17.3f, 6.7f, 18.0f, 7.5f, 18.0f) + reflectiveCurveTo(9.0f, 17.3f, 9.0f, 16.5f) + curveTo(9.0f, 15.7f, 8.3f, 15.0f, 7.5f, 15.0f) + close() + } + } + return _dice!! + } + +private var _dice: ImageVector? = null \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt new file mode 100644 index 0000000..6f8f25d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt @@ -0,0 +1,3 @@ +package org.xtimms.tokusho.core.database + +const val TABLE_SOURCES = "sources" \ 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 new file mode 100644 index 0000000..164961d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt @@ -0,0 +1,27 @@ +package org.xtimms.tokusho.core.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import org.xtimms.tokusho.core.database.dao.MangaSourcesDao +import org.xtimms.tokusho.core.database.entity.MangaSourceEntity + +const val DATABASE_VERSION = 1 + +@Database( + entities = [ + MangaSourceEntity::class + ], + version = DATABASE_VERSION, + exportSchema = false +) +abstract class MangaDatabase : RoomDatabase() { + + abstract fun getSourcesDao(): MangaSourcesDao + +} + +fun MangaDatabase(context: Context): MangaDatabase = Room + .databaseBuilder(context, MangaDatabase::class.java, "tokusho-db") + .build() \ No newline at end of file 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 new file mode 100644 index 0000000..2e9e705 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt @@ -0,0 +1,29 @@ +package org.xtimms.tokusho.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.xtimms.tokusho.core.database.entity.MangaSourceEntity + +@Dao +abstract class MangaSourcesDao { + + @Query("SELECT * FROM sources ORDER BY sort_key") + abstract suspend fun findAll(): List + + @Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key") + abstract suspend fun findAllDisabled(): List + + @Query("SELECT * FROM sources WHERE enabled = 0") + abstract fun observeDisabled(): Flow> + + @Query("SELECT * FROM sources ORDER BY sort_key") + abstract fun observeAll(): Flow> + + @Query("SELECT IFNULL(MAX(sort_key),0) FROM sources") + abstract suspend fun getMaxSortKey(): Int + + @Query("UPDATE sources SET enabled = 0") + abstract suspend fun disableAllSources() + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt new file mode 100644 index 0000000..c1ea648 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt @@ -0,0 +1,17 @@ +package org.xtimms.tokusho.core.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.xtimms.tokusho.core.database.TABLE_SOURCES + +@Entity( + tableName = TABLE_SOURCES, +) +data class MangaSourceEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "source") + val source: String, + @ColumnInfo(name = "enabled") val isEnabled: Boolean, + @ColumnInfo(name = "sort_key", index = true) val sortKey: Int, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt b/app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt new file mode 100644 index 0000000..eeba04b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt @@ -0,0 +1,10 @@ +package org.xtimms.tokusho.core.model + +interface ListModel { + + override fun equals(other: Any?): Boolean + + fun areItemsTheSame(other: ListModel): Boolean + + fun getChangePayload(previousState: ListModel): Any? = null +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt b/app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt new file mode 100644 index 0000000..7b04905 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt @@ -0,0 +1,25 @@ +package org.xtimms.tokusho.core.model + +import androidx.annotation.StringRes +import org.xtimms.tokusho.R +import org.koitharu.kotatsu.parsers.util.find +import java.util.EnumSet + +enum class ListSortOrder( + @StringRes val titleResId: Int, +) { + + NEWEST(R.string.order_added), + PROGRESS(R.string.progress), + ALPHABETIC(R.string.by_name), + ; + + fun isGroupingSupported() = this == NEWEST || this == PROGRESS + + companion object { + + val SHELF: Set = EnumSet.of(NEWEST, PROGRESS, ALPHABETIC) + + operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback + } +} \ 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 new file mode 100644 index 0000000..feac6a1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt @@ -0,0 +1,10 @@ +package org.xtimms.tokusho.core.model + +import org.koitharu.kotatsu.parsers.model.MangaSource + +fun MangaSource(name: String): MangaSource { + MangaSource.entries.forEach { + if (it.name == name) return it + } + return MangaSource.DUMMY +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt b/app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt new file mode 100644 index 0000000..a64b448 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt @@ -0,0 +1,17 @@ +package org.xtimms.tokusho.core.model + +import java.io.Serializable + +data class ShelfCategory( + val id: Long, + val name: String, + val order: Long, + val flags: Long, +) : Serializable { + + val isSystemCategory: Boolean = id == UNCATEGORIZED_ID + + companion object { + const val UNCATEGORIZED_ID = 0L + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt new file mode 100644 index 0000000..1097886 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt @@ -0,0 +1,79 @@ +package org.xtimms.tokusho.core.motion + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith + +private const val ProgressThreshold = 0.35f + +private val Int.ForOutgoing: Int + get() = (this * ProgressThreshold).toInt() + +private val Int.ForIncoming: Int + get() = this - this.ForOutgoing + +/** + * [materialSharedAxisX] allows to switch a layout with shared X-axis transition. + * + */ +public fun materialSharedAxisX( + initialOffsetX: (fullWidth: Int) -> Int, + targetOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): ContentTransform = materialSharedAxisXIn( + initialOffsetX = initialOffsetX, + durationMillis = durationMillis +) togetherWith materialSharedAxisXOut( + targetOffsetX = targetOffsetX, + durationMillis = durationMillis +) + +/** + * [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. + */ +public fun materialSharedAxisXIn( + initialOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): EnterTransition = slideInHorizontally( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + initialOffsetX = initialOffsetX +) + fadeIn( + animationSpec = tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing + ) +) + +/** + * [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. + * + */ +public fun materialSharedAxisXOut( + targetOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): ExitTransition = slideOutHorizontally( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + targetOffsetX = targetOffsetX +) + fadeOut( + animationSpec = tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing + ) +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt new file mode 100644 index 0000000..4a1e3a5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.core.motion + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +public object MotionConstants { + public const val DefaultMotionDuration: Int = 300 + public const val DefaultFadeInDuration: Int = 150 + public const val DefaultFadeOutDuration: Int = 75 + public val DefaultSlideDistance: Dp = 30.dp +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt b/app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt new file mode 100644 index 0000000..6e25b0e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt @@ -0,0 +1,11 @@ +package org.xtimms.tokusho.core.network + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BaseHttpClient + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MangaHttpClient \ 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 new file mode 100644 index 0000000..076f9a8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt @@ -0,0 +1,69 @@ +package org.xtimms.tokusho.core.network + +import android.content.Context +import android.util.AndroidRuntimeException +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.Cache +import okhttp3.CookieJar +import okhttp3.OkHttpClient +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.data.LocalStorageManager +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface NetworkModule { + + @Binds + fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar + + companion object { + + @Provides + @Singleton + fun provideCookieJar( + @ApplicationContext context: Context + ): MutableCookieJar = try { + AndroidCookieJar() + } catch (e: AndroidRuntimeException) { + PreferencesCookieJar(context) + } + + @Provides + @Singleton + fun provideHttpCache( + localStorageManager: LocalStorageManager, + ): Cache = localStorageManager.createHttpCache() + + @Provides + @Singleton + @BaseHttpClient + fun provideBaseHttpClient( + cache: Cache, + cookieJar: CookieJar, + ): OkHttpClient = OkHttpClient.Builder().apply { + connectTimeout(20, TimeUnit.SECONDS) + readTimeout(60, TimeUnit.SECONDS) + writeTimeout(20, TimeUnit.SECONDS) + cookieJar(cookieJar) + cache(cache) + }.build() + + @Provides + @Singleton + @MangaHttpClient + fun provideMangaHttpClient( + @BaseHttpClient baseClient: OkHttpClient, + ): OkHttpClient = baseClient.newBuilder().build() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt new file mode 100644 index 0000000..fcf0e16 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt @@ -0,0 +1,55 @@ +package org.xtimms.tokusho.core.network.cookies + +import android.webkit.CookieManager +import androidx.annotation.WorkerThread +import androidx.core.util.Predicate +import okhttp3.Cookie +import okhttp3.HttpUrl +import org.xtimms.tokusho.utils.system.newBuilder +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class AndroidCookieJar : MutableCookieJar { + + private val cookieManager = CookieManager.getInstance() + + @WorkerThread + override fun loadForRequest(url: HttpUrl): List { + val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() + return rawCookie.split(';').mapNotNull { + Cookie.parse(url, it) + } + } + + @WorkerThread + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (cookies.isEmpty()) { + return + } + val urlString = url.toString() + for (cookie in cookies) { + cookieManager.setCookie(urlString, cookie.toString()) + } + } + + override fun removeCookies(url: HttpUrl, predicate: Predicate?) { + val cookies = loadForRequest(url) + if (cookies.isEmpty()) { + return + } + val urlString = url.toString() + for (c in cookies) { + if (predicate != null && !predicate.test(c)) { + continue + } + val nc = c.newBuilder() + .expiresAt(System.currentTimeMillis() - 100000) + .build() + cookieManager.setCookie(urlString, nc.toString()) + } + } + + override suspend fun clear() = suspendCoroutine { continuation -> + cookieManager.removeAllCookies(continuation::resume) + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt new file mode 100644 index 0000000..b6befb7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt @@ -0,0 +1,68 @@ +package org.xtimms.tokusho.core.network.cookies + +import android.util.Base64 +import okhttp3.Cookie +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +data class CookieWrapper( + val cookie: Cookie, +) { + + constructor(encodedString: String) : this( + ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use { + val name = it.readUTF() + val value = it.readUTF() + val expiresAt = it.readLong() + val domain = it.readUTF() + val path = it.readUTF() + val secure = it.readBoolean() + val httpOnly = it.readBoolean() + val persistent = it.readBoolean() + val hostOnly = it.readBoolean() + Cookie.Builder().also { c -> + c.name(name) + c.value(value) + if (persistent) { + c.expiresAt(expiresAt) + } + if (hostOnly) { + c.hostOnlyDomain(domain) + } else { + c.domain(domain) + } + c.path(path) + if (secure) { + c.secure() + } + if (httpOnly) { + c.httpOnly() + } + }.build() + }, + ) + + fun encode(): String { + val output = ByteArrayOutputStream() + ObjectOutputStream(output).use { + it.writeUTF(cookie.name) + it.writeUTF(cookie.value) + it.writeLong(cookie.expiresAt) + it.writeUTF(cookie.domain) + it.writeUTF(cookie.path) + it.writeBoolean(cookie.secure) + it.writeBoolean(cookie.httpOnly) + it.writeBoolean(cookie.persistent) + it.writeBoolean(cookie.hostOnly) + } + return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP) + } + + fun isExpired() = cookie.expiresAt < System.currentTimeMillis() + + fun key(): String { + return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt new file mode 100644 index 0000000..32419ce --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt @@ -0,0 +1,21 @@ +package org.xtimms.tokusho.core.network.cookies + +import androidx.annotation.WorkerThread +import androidx.core.util.Predicate +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +interface MutableCookieJar : CookieJar { + + @WorkerThread + override fun loadForRequest(url: HttpUrl): List + + @WorkerThread + override fun saveFromResponse(url: HttpUrl, cookies: List) + + @WorkerThread + fun removeCookies(url: HttpUrl, predicate: Predicate?) + + suspend fun clear(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt new file mode 100644 index 0000000..2dc40b7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt @@ -0,0 +1,108 @@ +package org.xtimms.tokusho.core.network.cookies + +import android.content.Context +import androidx.annotation.WorkerThread +import androidx.collection.ArrayMap +import androidx.core.content.edit +import androidx.core.util.Predicate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Cookie +import okhttp3.HttpUrl + +private const val PREFS_NAME = "cookies" + +class PreferencesCookieJar( + context: Context, +) : MutableCookieJar { + + private val cache = ArrayMap() + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private var isLoaded = false + + @WorkerThread + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + loadPersistent() + val expired = HashSet() + val result = ArrayList() + for ((key, cookie) in cache) { + if (cookie.isExpired()) { + expired += key + } else if (cookie.cookie.matches(url)) { + result += cookie.cookie + } + } + if (expired.isNotEmpty()) { + cache.removeAll(expired) + removePersistent(expired) + } + return result + } + + @WorkerThread + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val wrapped = cookies.map { CookieWrapper(it) } + prefs.edit(commit = true) { + for (cookie in wrapped) { + val key = cookie.key() + cache[key] = cookie + if (cookie.cookie.persistent) { + putString(key, cookie.encode()) + } + } + } + } + + @Synchronized + @WorkerThread + override fun removeCookies(url: HttpUrl, predicate: Predicate?) { + loadPersistent() + val toRemove = HashSet() + for ((key, cookie) in cache) { + if (cookie.isExpired() || cookie.cookie.matches(url)) { + if (predicate == null || predicate.test(cookie.cookie)) { + toRemove += key + } + } + } + if (toRemove.isNotEmpty()) { + cache.removeAll(toRemove) + removePersistent(toRemove) + } + } + + override suspend fun clear(): Boolean { + cache.clear() + withContext(Dispatchers.IO) { + prefs.edit(commit = true) { clear() } + } + return true + } + + @Synchronized + private fun loadPersistent() { + if (!isLoaded) { + val map = prefs.all + cache.ensureCapacity(map.size) + for ((k, v) in map) { + val cookie = try { + CookieWrapper(v as String) + } catch (e: Exception) { + continue + } + cache[k] = cookie + } + isLoaded = true + } + } + + private fun removePersistent(keys: Collection) { + prefs.edit(commit = true) { + for (key in keys) { + remove(key) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt b/app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt new file mode 100644 index 0000000..2309c30 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt @@ -0,0 +1,50 @@ +package org.xtimms.tokusho.core.os + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.flow.first +import org.xtimms.tokusho.utils.MediatorStateFlow +import org.xtimms.tokusho.utils.system.isOnline + +class NetworkState( + private val connectivityManager: ConnectivityManager, +) : MediatorStateFlow(connectivityManager.isOnline()) { + + private val callback = NetworkCallbackImpl() + + @Synchronized + override fun onActive() { + invalidate() + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + } + + @Synchronized + override fun onInactive() { + connectivityManager.unregisterNetworkCallback(callback) + } + + suspend fun awaitForConnection() { + if (value) { + return + } + first { it } + } + + private fun invalidate() { + publishValue(connectivityManager.isOnline()) + } + + private inner class NetworkCallbackImpl : ConnectivityManager.NetworkCallback() { + + override fun onAvailable(network: Network) = invalidate() + + override fun onLost(network: Network) = invalidate() + + override fun onUnavailable() = invalidate() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt new file mode 100644 index 0000000..93d414b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt @@ -0,0 +1,4 @@ +package org.xtimms.tokusho.core.parser + +private const val MAX_PARALLELISM = 4 + diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt new file mode 100644 index 0000000..31d16c5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt @@ -0,0 +1,63 @@ +package org.xtimms.tokusho.core.parser + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Base64 +import android.webkit.WebView +import androidx.core.os.LocaleListCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.config.MangaSourceConfig +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.core.network.MangaHttpClient +import org.xtimms.tokusho.core.network.cookies.MutableCookieJar +import org.xtimms.tokusho.core.prefs.SourceSettings +import org.xtimms.tokusho.utils.system.toList +import java.lang.ref.WeakReference +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@Singleton +class MangaLoaderContextImpl @Inject constructor( + @MangaHttpClient override val httpClient: OkHttpClient, + override val cookieJar: MutableCookieJar, + @ApplicationContext private val androidContext: Context, +) : MangaLoaderContext() { + + private var webViewCached: WeakReference? = null + + @SuppressLint("SetJavaScriptEnabled") + override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) { + val webView = webViewCached?.get() ?: WebView(androidContext).also { + it.settings.javaScriptEnabled = true + webViewCached = WeakReference(it) + } + suspendCoroutine { cont -> + webView.evaluateJavascript(script) { result -> + cont.resume(result?.takeUnless { it == "null" }) + } + } + } + + override fun getConfig(source: MangaSource): MangaSourceConfig { + return SourceSettings(androidContext, source) + } + + override fun encodeBase64(data: ByteArray): String { + return Base64.encodeToString(data, Base64.NO_WRAP) + } + + override fun decodeBase64(data: String): ByteArray { + return Base64.decode(data, Base64.DEFAULT) + } + + override fun getPreferredLocales(): List { + return LocaleListCompat.getAdjustedDefault().toList() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt new file mode 100644 index 0000000..ec87920 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt @@ -0,0 +1,9 @@ +package org.xtimms.tokusho.core.parser + +import org.koitharu.kotatsu.parsers.MangaLoaderContext +import org.koitharu.kotatsu.parsers.MangaParser +import org.koitharu.kotatsu.parsers.model.MangaSource + +fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser { + return loaderContext.newParserInstance(source) +} \ 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 new file mode 100644 index 0000000..0357f0c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt @@ -0,0 +1,73 @@ +package org.xtimms.tokusho.core.parser + +import androidx.annotation.AnyThread +import org.koitharu.kotatsu.parsers.MangaLoaderContext +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.xtimms.tokusho.core.cache.ContentCache +import java.lang.ref.WeakReference +import java.util.EnumMap +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +interface MangaRepository { + + val source: MangaSource + + val sortOrders: Set + + val states: Set + + val contentRatings: Set + + val isMultipleTagsSupported: Boolean + + val isTagsExclusionSupported: Boolean + + val isSearchSupported: Boolean + + suspend fun getList(offset: Int, filter: MangaListFilter?): List + + suspend fun getDetails(manga: Manga): Manga + + suspend fun getPages(chapter: MangaChapter): List + + suspend fun getPageUrl(page: MangaPage): String + + suspend fun getTags(): Set + + suspend fun getLocales(): Set + + suspend fun getRelated(seed: Manga): List + + @Singleton + class Factory @Inject constructor( + private val loaderContext: MangaLoaderContext, + private val contentCache: ContentCache, + ) { + + private val cache = EnumMap>(MangaSource::class.java) + + @AnyThread + fun create(source: MangaSource): MangaRepository { + cache[source]?.get()?.let { return it } + return synchronized(cache) { + cache[source]?.get()?.let { return it } + val repository = RemoteMangaRepository( + parser = MangaParser(source, loaderContext), + cache = contentCache, + ) + cache[source] = WeakReference(repository) + repository + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..e1b77d2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt @@ -0,0 +1,144 @@ +package org.xtimms.tokusho.core.parser + +import android.util.Log +import coil.request.CachePolicy +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import okhttp3.Interceptor +import okhttp3.Response +import org.koitharu.kotatsu.parsers.MangaParser +import org.koitharu.kotatsu.parsers.exception.ParseException +import org.koitharu.kotatsu.parsers.model.ContentRating +import org.koitharu.kotatsu.parsers.model.Favicons +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.BuildConfig +import org.xtimms.tokusho.core.cache.ContentCache +import org.xtimms.tokusho.core.cache.SafeDeferred +import org.xtimms.tokusho.utils.lang.processLifecycleScope +import java.util.Locale + +class RemoteMangaRepository( + private val parser: MangaParser, + private val cache: ContentCache, +) : MangaRepository, Interceptor { + + override val source: MangaSource + get() = parser.source + + override val sortOrders: Set + get() = parser.availableSortOrders + + override val states: Set + get() = parser.availableStates + + override val contentRatings: Set + get() = parser.availableContentRating + + override val isMultipleTagsSupported: Boolean + get() = parser.isMultipleTagsSupported + + override val isSearchSupported: Boolean + get() = parser.isSearchSupported + + override val isTagsExclusionSupported: Boolean + get() = parser.isTagsExclusionSupported + + override fun intercept(chain: Interceptor.Chain): Response { + return if (parser is Interceptor) { + parser.intercept(chain) + } else { + chain.proceed(chain.request()) + } + } + + override suspend fun getList(offset: Int, filter: MangaListFilter?): List { + return parser.getList(offset, filter) + } + + override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) + + override suspend fun getPages(chapter: MangaChapter): List { + cache.getPages(source, chapter.url)?.let { return it } + val pages = asyncSafe { + parser.getPages(chapter).distinctById() + } + cache.putPages(source, chapter.url, pages) + return pages.await() + } + + override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page) + + override suspend fun getTags(): Set = parser.getAvailableTags() + + override suspend fun getLocales(): Set { + return parser.getAvailableLocales() + } + + suspend fun getFavicons(): Favicons = parser.getFavicons() + + override suspend fun getRelated(seed: Manga): List { + cache.getRelatedManga(source, seed.url)?.let { return it } + val related = asyncSafe { + parser.getRelatedManga(seed).filterNot { it.id == seed.id } + } + cache.putRelatedManga(source, seed.url, related) + return related.await() + } + + suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga { + if (cachePolicy.readEnabled) { + cache.getDetails(source, manga.url)?.let { return it } + } + val details = asyncSafe { + parser.getDetails(manga) + } + if (cachePolicy.writeEnabled) { + cache.putDetails(source, manga.url, details) + } + return details.await() + } + + @OptIn(ExperimentalStdlibApi::class) + private suspend fun asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred { + var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] + if (dispatcher == null || dispatcher is MainCoroutineDispatcher) { + dispatcher = Dispatchers.Default + } + return SafeDeferred( + processLifecycleScope.async(dispatcher) { + runCatchingCancellable { block() } + }, + ) + } + + private fun List.distinctById(): List { + if (isEmpty()) { + return emptyList() + } + val result = ArrayList(size) + val set = HashSet(size) + for (page in this) { + if (set.add(page.id)) { + result.add(page) + } else if (BuildConfig.DEBUG) { + Log.w(null, "Duplicate page: $page") + } + } + return result + } + + private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException + && (getOrNull() as? Collection<*>)?.isEmpty() != true +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt new file mode 100644 index 0000000..1b26401 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt @@ -0,0 +1,194 @@ +package org.xtimms.tokusho.core.parser.favicon + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.disk.DiskCache +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.network.HttpException +import coil.request.Options +import coil.size.Size +import coil.size.pxOrElse +import kotlinx.coroutines.ensureActive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.internal.closeQuietly +import okio.Closeable +import okio.buffer +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.await +import org.xtimms.tokusho.core.cache.CacheDir +import org.xtimms.tokusho.core.model.MangaSource +import org.xtimms.tokusho.core.parser.MangaRepository +import org.xtimms.tokusho.core.parser.RemoteMangaRepository +import org.xtimms.tokusho.utils.lang.writeAllCancellable +import org.xtimms.tokusho.utils.withExtraCloseable +import java.net.HttpURLConnection +import kotlin.coroutines.coroutineContext + +private const val FALLBACK_SIZE = 9999 // largest icon + +@OptIn(ExperimentalCoilApi::class) +class FaviconFetcher( + private val okHttpClient: OkHttpClient, + private val diskCache: Lazy, + private val mangaSource: MangaSource, + private val options: Options, + private val mangaRepositoryFactory: MangaRepository.Factory, +) : Fetcher { + + private val diskCacheKey + get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}" + + private val fileSystem + get() = checkNotNull(diskCache.value).fileSystem + + override suspend fun fetch(): FetchResult { + getCached(options)?.let { return it } + val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository + val sizePx = maxOf( + options.size.width.pxOrElse { FALLBACK_SIZE }, + options.size.height.pxOrElse { FALLBACK_SIZE }, + ) + var favicons = repo.getFavicons() + var lastError: Exception? = null + while (favicons.isNotEmpty()) { + coroutineContext.ensureActive() + val icon = favicons.find(sizePx) ?: throwNSEE(lastError) + val response = try { + loadIcon(icon.url, mangaSource) + } catch (e: HttpException) { + lastError = e + favicons -= icon + continue + } + val responseBody = response.requireBody() + val source = writeToDiskCache(responseBody)?.toImageSource()?.also { + response.closeQuietly() + } ?: responseBody.toImageSource(response) + return SourceResult( + source = source, + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type), + dataSource = response.toDataSource(), + ) + } + throwNSEE(lastError) + } + + private suspend fun loadIcon(url: String, source: MangaSource): Response { + val request = Request.Builder() + .url(url) + .get() + .tag(MangaSource::class.java, source) + @Suppress("UNCHECKED_CAST") + options.tags.asMap().forEach { request.tag(it.key as Class, it.value) } + val response = okHttpClient.newCall(request.build()).await() + if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) { + response.closeQuietly() + throw HttpException(response) + } + return response + } + + private fun getCached(options: Options): SourceResult? { + if (!options.diskCachePolicy.readEnabled) { + return null + } + val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null + return SourceResult( + source = snapshot.toImageSource(), + mimeType = null, + dataSource = DataSource.DISK, + ) + } + + private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? { + if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { + return null + } + val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null + try { + fileSystem.write(editor.data) { + writeAllCancellable(body.source()) + } + return editor.commitAndOpenSnapshot() + } catch (e: Throwable) { + try { + editor.abort() + } catch (abortingError: Throwable) { + e.addSuppressed(abortingError) + } + body.closeQuietly() + throw e + } finally { + body.closeQuietly() + } + } + + private fun DiskCache.Snapshot.toImageSource(): ImageSource { + return ImageSource(data, fileSystem, diskCacheKey, this) + } + + private fun ResponseBody.toImageSource(response: Closeable): ImageSource { + return ImageSource( + source().withExtraCloseable(response).buffer(), + options.context, + FaviconMetadata(mangaSource), + ) + } + + private fun Response.toDataSource(): DataSource { + return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK + } + + private fun Response.requireBody(): ResponseBody { + return checkNotNull(body) { "response body == null" } + } + + private fun Size.toCacheKey() = buildString { + append(width.toString()) + append('x') + append(height.toString()) + } + + private fun throwNSEE(lastError: Exception?): Nothing { + if (lastError != null) { + throw lastError + } else { + throw NoSuchElementException("No favicons found") + } + } + + class Factory( + context: Context, + private val okHttpClient: OkHttpClient, + private val mangaRepositoryFactory: MangaRepository.Factory, + ) : Fetcher.Factory { + + private val diskCache = lazy { + val rootDir = context.externalCacheDir ?: context.cacheDir + DiskCache.Builder() + .directory(rootDir.resolve(CacheDir.FAVICONS.dir)) + .build() + } + + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { + return if (data.scheme == URI_SCHEME_FAVICON) { + val mangaSource = MangaSource(data.schemeSpecificPart) + FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory) + } else { + null + } + } + } + + class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt new file mode 100644 index 0000000..1600b8f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.core.parser.favicon + +import android.net.Uri +import org.koitharu.kotatsu.parsers.model.MangaSource + +const val URI_SCHEME_FAVICON = "favicon" + +fun MangaSource.faviconUri(): Uri = Uri.fromParts(URI_SCHEME_FAVICON, name, null) \ No newline at end of file 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 new file mode 100644 index 0000000..76764cb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt @@ -0,0 +1,190 @@ +package org.xtimms.tokusho.core.prefs + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.os.LocaleListCompat +import com.google.android.material.color.DynamicColors +import com.tencent.mmkv.MMKV +import kotlinx.coroutines.Dispatchers +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.App.Companion.applicationScope +import org.xtimms.tokusho.R +import org.xtimms.tokusho.ui.monet.PaletteStyle +import org.xtimms.tokusho.utils.system.languageMap + +private const val DYNAMIC_COLOR = "dynamic_color" +const val DARK_THEME_VALUE = "dark_theme_value" +private const val HIGH_CONTRAST = "high_contrast" +const val AUTO_UPDATE = "auto_update" +const val UPDATE_CHANNEL = "update_channel" +private const val THEME_COLOR = "theme_color" +const val PALETTE_STYLE = "palette_style" +const val LANGUAGE = "language" + +const val SYSTEM_DEFAULT = 0 + +const val STABLE = 0 +const val PRE_RELEASE = 1 + +val paletteStyles = listOf( + PaletteStyle.TonalSpot, + PaletteStyle.Spritz, + PaletteStyle.FruitSalad, + PaletteStyle.Vibrant, + PaletteStyle.Monochrome +) + +const val STYLE_TONAL_SPOT = 0 +const val STYLE_SPRITZ = 1 +const val STYLE_FRUIT_SALAD = 2 +const val STYLE_VIBRANT = 3 +const val STYLE_MONOCHROME = 4 + +private val kv: MMKV = MMKV.defaultMMKV() + +private val StringPreferenceDefaults = mapOf( + "test" to "default", +) + +private val BooleanPreferenceDefaults = mapOf( + "test" to false +) + +private val IntPreferenceDefaults = mapOf( + LANGUAGE to SYSTEM_DEFAULT, + PALETTE_STYLE to 0, + DARK_THEME_VALUE to DarkThemePreference.FOLLOW_SYSTEM, + UPDATE_CHANNEL to STABLE, +) + +object AppSettings { + + fun String.getInt(default: Int = IntPreferenceDefaults.getOrElse(this) { 0 }): Int = + kv.decodeInt(this, default) + + fun String.getString(default: String = StringPreferenceDefaults.getOrElse(this) { "" }): String = + kv.decodeString(this) ?: default + + fun String.getBoolean(default: Boolean = BooleanPreferenceDefaults.getOrElse(this) { false }): Boolean = + kv.decodeBool(this, default) + + fun String.updateString(newString: String) = kv.encode(this, newString) + + fun String.updateInt(newInt: Int) = kv.encode(this, newInt) + + fun String.updateBoolean(newValue: Boolean) = kv.encode(this, newValue) + fun updateValue(key: String, b: Boolean) = key.updateBoolean(b) + fun encodeInt(key: String, int: Int) = key.updateInt(int) + fun getValue(key: String): Boolean = key.getBoolean() + fun encodeString(key: String, string: String) = key.updateString(string) + fun containsKey(key: String) = kv.containsKey(key) + + fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(true) + + fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) = + languageMap.getOrElse(languageNumber) { "" } + + + private fun getLanguageNumberByCode(languageCode: String): Int = + languageMap.entries.find { it.value == languageCode }?.key ?: SYSTEM_DEFAULT + + + fun getLanguageNumber(): Int { + return if (Build.VERSION.SDK_INT >= 33) getLanguageNumberByCode( + LocaleListCompat.getAdjustedDefault()[0]?.toLanguageTag().toString() + ) + else LANGUAGE.getInt() + } + + data class Settings( + val darkTheme: DarkThemePreference = DarkThemePreference(), + val isDynamicColorEnabled: Boolean = false, + val seedColor: Int = SEED, + val paletteStyleIndex: Int = 0 + ) + + private val mutableAppSettingsStateFlow = MutableStateFlow( + Settings( + DarkThemePreference( + darkThemeValue = kv.decodeInt( + DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM + ), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false) + ), + isDynamicColorEnabled = kv.decodeBool( + DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable() + ), + seedColor = kv.decodeInt(THEME_COLOR, SEED), + paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0) + ) + ) + val AppSettingsStateFlow = mutableAppSettingsStateFlow.asStateFlow() + + fun modifyDarkThemePreference( + darkThemeValue: Int = AppSettingsStateFlow.value.darkTheme.darkThemeValue, + isHighContrastModeEnabled: Boolean = AppSettingsStateFlow.value.darkTheme.isHighContrastModeEnabled + ) { + applicationScope.launch(Dispatchers.IO) { + mutableAppSettingsStateFlow.update { + it.copy( + darkTheme = AppSettingsStateFlow.value.darkTheme.copy( + darkThemeValue = darkThemeValue, + isHighContrastModeEnabled = isHighContrastModeEnabled + ) + ) + } + kv.encode(DARK_THEME_VALUE, darkThemeValue) + kv.encode(HIGH_CONTRAST, isHighContrastModeEnabled) + } + } + + fun modifyThemeSeedColor(colorArgb: Int, paletteStyleIndex: Int) { + applicationScope.launch(Dispatchers.IO) { + mutableAppSettingsStateFlow.update { + it.copy(seedColor = colorArgb, paletteStyleIndex = paletteStyleIndex) + } + kv.encode(THEME_COLOR, colorArgb) + kv.encode(PALETTE_STYLE, paletteStyleIndex) + } + } + + fun switchDynamicColor(enabled: Boolean = !mutableAppSettingsStateFlow.value.isDynamicColorEnabled) { + applicationScope.launch(Dispatchers.IO) { + mutableAppSettingsStateFlow.update { + it.copy(isDynamicColorEnabled = enabled) + } + kv.encode(DYNAMIC_COLOR, enabled) + } + } +} + +data class DarkThemePreference( + val darkThemeValue: Int = FOLLOW_SYSTEM, val isHighContrastModeEnabled: Boolean = false +) { + companion object { + const val FOLLOW_SYSTEM = 1 + const val ON = 2 + const val OFF = 3 + } + + @Composable + fun isDarkTheme(): Boolean { + return if (darkThemeValue == FOLLOW_SYSTEM) isSystemInDarkTheme() + else darkThemeValue == ON + } + + @Composable + fun getDarkThemeDesc(): String { + return when (darkThemeValue) { + FOLLOW_SYSTEM -> stringResource(R.string.follow_system) + ON -> stringResource(R.string.on) + else -> stringResource(R.string.off) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt new file mode 100644 index 0000000..1822c34 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt @@ -0,0 +1,43 @@ +package org.xtimms.tokusho.core.prefs + +import android.content.Context +import androidx.core.content.edit +import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.config.MangaSourceConfig +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.xtimms.tokusho.utils.lang.ifNullOrEmpty +import org.xtimms.tokusho.utils.system.getEnumValue +import org.xtimms.tokusho.utils.system.putEnumValue + +private const val KEY_SORT_ORDER = "sort_order" +private const val KEY_SLOWDOWN = "slowdown" + +class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { + + private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) + + var defaultSortOrder: SortOrder? + get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) + set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } + + val isSlowdownEnabled: Boolean + get() = prefs.getBoolean(KEY_SLOWDOWN, false) + + @Suppress("UNCHECKED_CAST") + override fun get(key: ConfigKey): T { + return when (key) { + is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } + is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } + is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) + } as T + } + + operator fun set(key: ConfigKey, value: T) = prefs.edit { + when (key) { + is ConfigKey.Domain -> putString(key.key, value as String?) + is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean) + is ConfigKey.UserAgent -> putString(key.key, value as String?) + } + } +} \ 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 new file mode 100644 index 0000000..20c8307 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt @@ -0,0 +1,112 @@ +package org.xtimms.tokusho.core.screens + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection +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.util.fastForEach +import kotlinx.collections.immutable.ImmutableList +import org.xtimms.tokusho.core.components.ActionButton +import org.xtimms.tokusho.utils.secondaryItemAlpha +import kotlin.random.Random + +data class EmptyScreenAction( + val stringRes: Int, + val icon: ImageVector, + val onClick: () -> Unit, +) + +@Composable +fun EmptyScreen( + @StringRes title: Int, + modifier: Modifier = Modifier, + actions: ImmutableList? = null, +) { + EmptyScreen( + message = stringResource(title), + modifier = modifier, + actions = actions, + ) +} + +@Composable +fun EmptyScreen( + message: String, + modifier: Modifier = Modifier, + actions: ImmutableList? = null, +) { + val face = remember { getRandomErrorFace() } + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Text( + text = face, + modifier = Modifier.secondaryItemAlpha(), + style = MaterialTheme.typography.displayMedium, + ) + } + + Text( + text = message, + modifier = Modifier + .paddingFromBaseline(top = 24.dp) + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + + if (!actions.isNullOrEmpty()) { + Row( + modifier = Modifier + .padding(top = 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + actions.fastForEach { + ActionButton( + modifier = Modifier.weight(1f), + title = stringResource(it.stringRes), + icon = it.icon, + onClick = it.onClick, + ) + } + } + } + } +} + +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/screens/InfoScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt new file mode 100644 index 0000000..2959bf4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt @@ -0,0 +1,142 @@ +package org.xtimms.tokusho.core.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Newspaper +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import org.xtimms.tokusho.utils.secondaryItemAlpha + +@Composable +fun InfoScreen( + icon: ImageVector, + headingText: String, + subtitleText: String, + acceptText: String, + onAcceptClick: () -> Unit, + canAccept: Boolean = true, + rejectText: String? = null, + onRejectClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + bottomBar = { + val strokeWidth = Dp.Hairline + val borderColor = MaterialTheme.colorScheme.outline + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .drawBehind { + drawLine( + borderColor, + Offset(0f, 0f), + Offset(size.width, 0f), + strokeWidth.value, + ) + } + .windowInsetsPadding(NavigationBarDefaults.windowInsets) + .padding( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = canAccept, + onClick = onAcceptClick, + ) { + Text(text = acceptText) + } + if (rejectText != null && onRejectClick != null) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onRejectClick, + ) { + Text(text = rejectText) + } + } + } + }, + ) { paddingValues -> + // Status bar scrim + Box( + modifier = Modifier + .zIndex(2f) + .secondaryItemAlpha() + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth() + .height(paddingValues.calculateTopPadding()), + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(paddingValues) + .padding(top = 48.dp) + .padding(horizontal = 16.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(bottom = 8.dp) + .size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = headingText, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = subtitleText, + modifier = Modifier + .secondaryItemAlpha() + .padding(vertical = 8.dp), + style = MaterialTheme.typography.titleSmall, + ) + + content() + } + } +} + +@PreviewLightDark +@Composable +private fun InfoScaffoldPreview() { + InfoScreen( + icon = Icons.Outlined.Newspaper, + headingText = "Heading", + subtitleText = "Subtitle", + acceptText = "Accept", + onAcceptClick = {}, + rejectText = "Reject", + onRejectClick = {}, + ) { + Text("Hello world") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt new file mode 100644 index 0000000..b617fcd --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt @@ -0,0 +1,94 @@ +package org.xtimms.tokusho.core.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.updates.Updater +import org.xtimms.tokusho.utils.system.suspendToast + +@Composable +fun UpdateDialog( + onDismissRequest: () -> Unit, + latestRelease: Updater.LatestRelease, +) { + var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) } + val context = LocalContext.current + + val scope = rememberCoroutineScope() + UpdateDialogImpl( + onDismissRequest = onDismissRequest, + title = latestRelease.name.toString(), + onConfirmUpdate = { + scope.launch(Dispatchers.IO) { + runCatching { + Updater.downloadApk(latestRelease = latestRelease) + .collect { downloadStatus -> + currentDownloadStatus = downloadStatus + if (downloadStatus is Updater.DownloadStatus.Finished) { + Updater.installLatestApk() + } + } + }.onFailure { + it.printStackTrace() + currentDownloadStatus = Updater.DownloadStatus.NotYet + context.suspendToast(R.string.app_update_failed) + return@launch + } + } + }, + releaseNote = latestRelease.body.toString(), + downloadStatus = currentDownloadStatus + ) +} + +@Composable +fun UpdateDialogImpl( + onDismissRequest: () -> Unit, + title: String, + onConfirmUpdate: () -> Unit, + releaseNote: String, + downloadStatus: Updater.DownloadStatus, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text(title) }, + icon = { Icon(Icons.Outlined.NewReleases, null) }, confirmButton = { + TextButton(onClick = { if (downloadStatus !is Updater.DownloadStatus.Progress) onConfirmUpdate() }) { + when (downloadStatus) { + is Updater.DownloadStatus.Progress -> Text("${downloadStatus.percent} %") + else -> Text(stringResource(R.string.update)) + } + } + }, dismissButton = { + DismissButton { onDismissRequest() } + }, text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text(releaseNote) + } + }) +} + +@Composable +fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text(text) + } +} \ 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 new file mode 100644 index 0000000..decc66a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt @@ -0,0 +1,299 @@ +package org.xtimms.tokusho.core.updates + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.ResponseBody +import org.xtimms.tokusho.App +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.prefs.AppSettings.getInt +import org.xtimms.tokusho.core.prefs.STABLE +import org.xtimms.tokusho.core.prefs.UPDATE_CHANNEL +import org.xtimms.tokusho.utils.system.getFileProvider +import org.xtimms.tokusho.utils.system.toast +import java.io.File +import java.util.regex.Pattern + +object Updater { + + private const val OWNER = "ztimms73" + private const val REPO = "Tokusho" + private const val TAG = "Updates" + + private val client = OkHttpClient() + + private val requestForReleases = + Request.Builder().url("https://api.github.com/repos/$OWNER/$REPO/releases") + .build() + + private val jsonFormat = Json { ignoreUnknownKeys = true } + + private suspend fun getLatestRelease(): LatestRelease = + client.newCall(requestForReleases).execute().run { + val releaseList = + jsonFormat.decodeFromString>(this.body!!.string()) + val latestRelease = + releaseList.filter { if (UPDATE_CHANNEL.getInt() == STABLE) it.name.toVersion() is Version.Stable else true } + .maxByOrNull { it.name.toVersion() } + ?: throw Exception("null response") + releaseList.sortedBy { it.name.toVersion() }.forEach { + Log.d(TAG, it.tagName.toString()) + } + body!!.close() + latestRelease + } + + suspend fun checkForUpdate(context: Context = App.context): LatestRelease? { + val currentVersion = context.getCurrentVersion() + val latestRelease = getLatestRelease() + val latestVersion = latestRelease.name.toVersion() + return if (currentVersion < latestVersion) latestRelease + else null + } + + private fun Context.getCurrentVersion(): Version = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo( + packageName, PackageManager.PackageInfoFlags.of(0) + ).versionName.toVersion() + } else { + packageManager.getPackageInfo( + packageName, 0 + ).versionName.toVersion() + } + + private fun Context.getLatestApk() = + File(getExternalFilesDir("apk"), "latest.apk") + + fun installLatestApk(context: Context = App.context) = context.run { + kotlin.runCatching { + val contentUri = FileProvider.getUriForFile(this, getFileProvider(), getLatestApk()) + val intent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(contentUri, "application/vnd.android.package-archive") + } + startActivity(intent) + }.onFailure { throwable: Throwable -> + throwable.printStackTrace() + context.toast(R.string.app_update_failed) + } + } + + suspend fun deleteOutdatedApk( + context: Context = App.context, + ) = context.runCatching { + val apkFile = getLatestApk() + if (apkFile.exists()) { + val apkVersion = context.packageManager.getPackageArchiveInfo( + apkFile.absolutePath, 0 + )?.versionName.toVersion() + if (apkVersion <= context.getCurrentVersion()) { + apkFile.delete() + } + } + } + + suspend fun downloadApk( + context: Context = App.context, + latestRelease: LatestRelease + ): Flow = withContext(Dispatchers.IO) { + val apkVersion = context.packageManager.getPackageArchiveInfo( + context.getLatestApk().absolutePath, 0 + )?.versionName.toVersion() + + Log.d(TAG, apkVersion.toString()) + + if (apkVersion >= latestRelease.name.toVersion()) { + return@withContext flow { emit(DownloadStatus.Finished(context.getLatestApk())) } + } + + val abiList = Build.SUPPORTED_ABIS + val preferredArch = abiList.firstOrNull() ?: return@withContext emptyFlow() + + val targetUrl = latestRelease.assets?.find { + return@find it.name?.contains(preferredArch) ?: false + }?.browserDownloadUrl ?: return@withContext emptyFlow() + val request = Request.Builder().url(targetUrl).build() + try { + val response = client.newCall(request).execute() + val responseBody = response.body + if (responseBody != null) { + return@withContext responseBody.downloadFileWithProgress(context.getLatestApk()) + } + } catch (e: Exception) { + e.printStackTrace() + } + emptyFlow() + } + + private fun ResponseBody.downloadFileWithProgress(saveFile: File): Flow = flow { + emit(DownloadStatus.Progress(0)) + + var deleteFile = true + + try { + byteStream().use { inputStream -> + saveFile.outputStream().use { outputStream -> + val totalBytes = contentLength() + val data = ByteArray(8_192) + var progressBytes = 0L + + while (true) { + val bytes = inputStream.read(data) + + if (bytes == -1) { + break + } + + outputStream.channel + outputStream.write(data, 0, bytes) + progressBytes += bytes + emit(DownloadStatus.Progress(percent = ((progressBytes * 100) / totalBytes).toInt())) + } + + when { + progressBytes < totalBytes -> throw Exception("missing bytes") + progressBytes > totalBytes -> throw Exception("too many bytes") + else -> deleteFile = false + } + } + } + + emit(DownloadStatus.Finished(saveFile)) + } finally { + if (deleteFile) { + saveFile.delete() + } + } + }.flowOn(Dispatchers.IO).distinctUntilChanged() + + @Serializable + data class LatestRelease( + @SerialName("html_url") val htmlUrl: String? = null, + @SerialName("tag_name") val tagName: String? = null, + val name: String? = null, + val draft: Boolean? = null, + @SerialName("prerelease") val preRelease: Boolean? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("published_at") val publishedAt: String? = null, + val assets: List? = null, + val body: String? = null, + ) + + @Serializable + data class AssetsItem( + val name: String? = null, + @SerialName("content_type") val contentType: String? = null, + val size: Int? = null, + @SerialName("download_count") val downloadCount: Int? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("browser_download_url") val browserDownloadUrl: String? = null, + ) + + sealed class DownloadStatus { + object NotYet : DownloadStatus() + data class Progress(val percent: Int) : DownloadStatus() + data class Finished(val file: File) : DownloadStatus() + } + + private val pattern = Pattern.compile("""v?(\d+)\.(\d+)\.(\d+)(-(\w+)\.(\d+))?""") + private val EMPTY_VERSION = Version.Stable() + + fun String?.toVersion(): Version = this?.run { + val matcher = pattern.matcher(this) + if (matcher.find()) { + val major = matcher.group(1)?.toInt() ?: 0 + val minor = matcher.group(2)?.toInt() ?: 0 + val patch = matcher.group(3)?.toInt() ?: 0 + val buildNumber = matcher.group(6)?.toInt() ?: 0 + when (matcher.group(5)) { + "beta" -> Version.Beta(major, minor, patch, buildNumber) + "rc" -> Version.ReleaseCandidate(major, minor, patch, buildNumber) + else -> Version.Stable(major, minor, patch) + } + } else EMPTY_VERSION + } ?: EMPTY_VERSION + + sealed class Version( + val major: Int, + val minor: Int, + val patch: Int, + val build: Int = 0 + ) : Comparable { + companion object { + private const val BUILD = 1L + private const val PATCH = 100L + private const val MINOR = 10_000L + private const val MAJOR = 1_000_000L + } + + abstract fun toVersionName(): String + abstract fun toNumber(): Long + + class Beta(versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int) : + Version(versionMajor, versionMinor, versionPatch, versionBuild) { + override fun toVersionName(): String = + "${major}.${minor}.${patch}-beta.$build" + + override fun toNumber(): Long = + major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + + } + + class Stable(versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0) : + Version(versionMajor, versionMinor, versionPatch) { + override fun toVersionName(): String = + "${major}.${minor}.${patch}" + + override fun toNumber(): Long = + major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 100 + // Prioritize stable versions + + } + + class ReleaseCandidate( + versionMajor: Int, + versionMinor: Int, + versionPatch: Int, + versionBuild: Int + ) : + Version(versionMajor, versionMinor, versionPatch, versionBuild) { + override fun toVersionName(): String = + "${major}.${minor}.${patch}-rc.$build" + + override fun toNumber(): Long = + major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 25 + } + + class Alpha(versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0) : + Version(versionMajor, versionMinor, versionPatch) { + override fun toVersionName(): String = + "${major}.${minor}.${patch}-alpha.$build" + + override fun toNumber(): Long = + major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 50 + + } + + override operator fun compareTo(other: Version): Int = + this.toNumber().compareTo(other.toNumber()) + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt b/app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt new file mode 100644 index 0000000..8dc3fd6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt @@ -0,0 +1,41 @@ +package org.xtimms.tokusho.crash + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint +import org.xtimms.tokusho.LocalDarkTheme +import org.xtimms.tokusho.LocalDynamicColorSwitch +import org.xtimms.tokusho.MainActivity +import org.xtimms.tokusho.SettingsProvider +import org.xtimms.tokusho.ui.theme.TokushoTheme + +@AndroidEntryPoint +class CrashActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val exception = GlobalExceptionHandler.getThrowableFromIntent(intent) + setContent { + SettingsProvider { + TokushoTheme( + darkTheme = LocalDarkTheme.current.isDarkTheme(), + isDynamicColorEnabled = LocalDynamicColorSwitch.current, + isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, + ) { + CrashScreen( + exception = exception, + onRestartClick = { + finishAffinity() + startActivity(Intent(this@CrashActivity, MainActivity::class.java)) + }, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt b/app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt new file mode 100644 index 0000000..3916c35 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt @@ -0,0 +1,71 @@ +package org.xtimms.tokusho.crash + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.screens.InfoScreen +import org.xtimms.tokusho.ui.theme.TokushoTheme +import org.xtimms.tokusho.utils.CrashLogUtil + +@Composable +fun CrashScreen( + exception: Throwable?, + onRestartClick: () -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + InfoScreen( + icon = Icons.Outlined.BugReport, + headingText = stringResource(R.string.crash_screen_title), + subtitleText = stringResource(R.string.crash_screen_description, stringResource(R.string.app_name)), + acceptText = stringResource(R.string.pref_dump_crash_logs), + onAcceptClick = { + scope.launch { + CrashLogUtil(context).dumpLogs() + } + }, + rejectText = stringResource(R.string.crash_screen_restart_application), + onRejectClick = onRestartClick, + ) { + Box( + modifier = Modifier + .padding(vertical = 8.dp) + .clip(MaterialTheme.shapes.small) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Text( + text = exception.toString(), + modifier = Modifier + .padding(all = 8.dp), + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun CrashScreenPreview() { + TokushoTheme { + CrashScreen(exception = RuntimeException("Dummy")) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt b/app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt new file mode 100644 index 0000000..443a493 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt @@ -0,0 +1,76 @@ +package org.xtimms.tokusho.crash + +import android.content.Context +import android.content.Intent +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlin.system.exitProcess + +class GlobalExceptionHandler private constructor( + private val applicationContext: Context, + private val defaultHandler: Thread.UncaughtExceptionHandler, + private val activityToBeLaunched: Class<*>, +) : Thread.UncaughtExceptionHandler { + + object ThrowableSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Throwable", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Throwable = + Throwable(message = decoder.decodeString()) + + override fun serialize(encoder: Encoder, value: Throwable) = + encoder.encodeString(value.stackTraceToString()) + } + + override fun uncaughtException(thread: Thread, exception: Throwable) { + try { + launchActivity(applicationContext, activityToBeLaunched, exception) + exitProcess(0) + } catch (_: Exception) { + defaultHandler.uncaughtException(thread, exception) + } + } + + private fun launchActivity( + applicationContext: Context, + activity: Class<*>, + exception: Throwable, + ) { + val intent = Intent(applicationContext, activity).apply { + putExtra(INTENT_EXTRA, Json.encodeToString(ThrowableSerializer, exception)) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + applicationContext.startActivity(intent) + } + + companion object { + private const val INTENT_EXTRA = "Throwable" + + fun initialize( + applicationContext: Context, + activityToBeLaunched: Class<*>, + ) { + val handler = GlobalExceptionHandler( + applicationContext, + Thread.getDefaultUncaughtExceptionHandler() as Thread.UncaughtExceptionHandler, + activityToBeLaunched, + ) + Thread.setDefaultUncaughtExceptionHandler(handler) + } + + fun getThrowableFromIntent(intent: Intent): Throwable? { + return try { + Json.decodeFromString(ThrowableSerializer, intent.getStringExtra(INTENT_EXTRA)!!) + } catch (e: Exception) { + null + } + } + } +} \ 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 new file mode 100644 index 0000000..fb5c134 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt @@ -0,0 +1,41 @@ +package org.xtimms.tokusho.data + +import android.content.Context +import android.os.StatFs +import androidx.annotation.WorkerThread +import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.Cache +import java.io.File +import javax.inject.Inject + +private const val DIR_NAME = "manga" +private const val NOMEDIA = ".nomedia" +private const val CACHE_DISK_PERCENTAGE = 0.02 +private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB +private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB + +@Reusable +class LocalStorageManager @Inject constructor( + @ApplicationContext private val context: Context +) { + + @WorkerThread + fun createHttpCache(): Cache { + val directory = File(context.externalCacheDir ?: context.cacheDir, "http") + directory.mkdirs() + val maxSize = calculateDiskCacheSize(directory) + return Cache(directory, maxSize) + } + + private fun calculateDiskCacheSize(cacheDirectory: File): Long { + return try { + val cacheDir = StatFs(cacheDirectory.absolutePath) + val size = CACHE_DISK_PERCENTAGE * cacheDir.blockCountLong * cacheDir.blockSizeLong + return size.toLong().coerceIn(CACHE_SIZE_MIN, CACHE_SIZE_MAX) + } catch (_: Exception) { + CACHE_SIZE_MIN + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..089356e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt @@ -0,0 +1,30 @@ +package org.xtimms.tokusho.data.repository + +import dagger.Reusable +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.BuildConfig +import org.xtimms.tokusho.core.database.MangaDatabase +import org.xtimms.tokusho.core.database.dao.MangaSourcesDao +import java.util.Collections +import java.util.EnumSet +import javax.inject.Inject + +@Reusable +class MangaSourcesRepository @Inject constructor( + private val db: MangaDatabase, +) { + + private val dao: MangaSourcesDao + get() = db.getSourcesDao() + + private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { + remove(MangaSource.LOCAL) + if (!BuildConfig.DEBUG) { + remove(MangaSource.DUMMY) + } + } + + val allMangaSources: Set + get() = Collections.unmodifiableSet(remoteSources) + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt new file mode 100644 index 0000000..eba7a3c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt @@ -0,0 +1,7 @@ +package org.xtimms.tokusho.sections.explore + +import org.xtimms.tokusho.core.base.event.UiEvent + +interface ExploreEvent : UiEvent { + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt new file mode 100644 index 0000000..d53429d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt @@ -0,0 +1,15 @@ +package org.xtimms.tokusho.sections.explore + +import coil.ImageLoader +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.xtimms.tokusho.core.base.state.UiState + +data class ExploreUiState( + val sources: List = emptyList(), + val coil: ImageLoader? = null, + 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/explore/ExploreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt new file mode 100644 index 0000000..82b9c94 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt @@ -0,0 +1,188 @@ +package org.xtimms.tokusho.sections.explore + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +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.SdStorage +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.clipToBounds +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import coil.ImageLoader +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.collapsable +import org.xtimms.tokusho.core.components.ExploreButton +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.sections.list.LIST_DESTINATION +import org.xtimms.tokusho.utils.system.toast + +const val EXPLORE_DESTINATION = "explore" + +@Composable +fun ExploreView( + coil: ImageLoader, + navController: NavController, + topBarHeightPx: Float, + topBarOffsetY: Animatable, + padding: PaddingValues, +) { + val viewModel: ExploreViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ExploreViewContent( + coil = coil, + navController = navController, + uiState = uiState, + event = viewModel, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY, + padding = padding + ) +} + +@Composable +fun ExploreViewContent( + coil: ImageLoader, + navController: NavController, + uiState: ExploreUiState, + event: ExploreEvent?, + nestedScrollConnection: NestedScrollConnection? = null, + topBarHeightPx: Float = 0f, + topBarOffsetY: Animatable = Animatable(0f), + padding: PaddingValues = PaddingValues(), +) { + + val context = LocalContext.current + val layoutDirection = LocalLayoutDirection.current + + val scrollState = rememberScrollState() + + if (uiState.message != null) { + LaunchedEffect(uiState.message) { + context.toast(uiState.message) + event?.onMessageDisplayed() + } + } + + Box( + modifier = Modifier + .clipToBounds() + .fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + val listState = rememberLazyGridState() + val listModifier = Modifier + .fillMaxWidth() + .align(Alignment.TopStart) + .then( + if (nestedScrollConnection != null) + Modifier.nestedScroll(nestedScrollConnection) + else Modifier + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 72.dp), + modifier = listModifier + .collapsable( + state = listState, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY, + ), + state = listState, + contentPadding = PaddingValues( + start = padding.calculateStartPadding(layoutDirection) + 8.dp, + top = padding.calculateTopPadding(), + end = padding.calculateEndPadding(layoutDirection) + 8.dp, + bottom = padding.calculateBottomPadding() + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + item( + span = { GridItemSpan(maxCurrentLineSpan) } + ) { + Row { + ExploreButton( + text = stringResource(R.string.local_storage), + icon = Icons.Outlined.SdStorage, + modifier = Modifier.weight(1f), + onClick = { } + ) + + ExploreButton( + text = stringResource(R.string.bookmarks), + icon = Icons.Outlined.Bookmarks, + modifier = Modifier.weight(1f), + onClick = { } + ) + } + } + item( + span = { GridItemSpan(maxCurrentLineSpan) } + ) { + Row { + ExploreButton( + text = stringResource(R.string.random), + icon = Icons.Outlined.Dice, + modifier = Modifier.weight(1f), + onClick = { }, + ) + + ExploreButton( + text = stringResource(R.string.downloads), + icon = Icons.Outlined.Download, + modifier = Modifier.weight(1f), + onClick = { throw IllegalAccessException() }, + ) + } + } + items( + items = uiState.sources, + key = { it.ordinal }, + contentType = { it } + ) { item -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + SourceItem( + coil = coil, + faviconUrl = item.faviconUri(), + title = item.title, + onClick = { + navController.navigate(LIST_DESTINATION) + } + ) + } + } + } + } +} \ 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 new file mode 100644 index 0000000..93e64a8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt @@ -0,0 +1,36 @@ +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 org.xtimms.tokusho.data.repository.MangaSourcesRepository +import javax.inject.Inject + +@HiltViewModel +class ExploreViewModel @Inject constructor( + private val mangaSourcesRepository: MangaSourcesRepository, +) : BaseViewModel(), ExploreEvent { + + override val mutableUiState = MutableStateFlow( + ExploreUiState( + isLoading = true, + ) + ) + + init { + viewModelScope.launch(Dispatchers.IO) { + val result = mangaSourcesRepository.allMangaSources + mutableUiState.update { + it.copy( + sources = result.toList(), + ) + } + setLoading(false) + } + } + +} \ 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 new file mode 100644 index 0000000..c164119 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt @@ -0,0 +1,65 @@ +package org.xtimms.tokusho.sections.history + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.ExperimentalFoundationApi +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.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.collapsable +import org.xtimms.tokusho.core.screens.EmptyScreen +import org.xtimms.tokusho.ui.theme.TokushoTheme + +const val HISTORY_DESTINATION = "history" + +@Composable +fun HistoryView( + topBarHeightPx: Float, + padding: PaddingValues, +) { + HistoryViewContent( + topBarHeightPx = topBarHeightPx, + padding = padding + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HistoryViewContent( + topBarHeightPx: Float, + topBarOffsetY: Animatable = Animatable(0f), + padding: PaddingValues, +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .collapsable( + state = scrollState, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY + ) + .padding(padding) + ) { + EmptyScreen(title = R.string.nothing_here) + } +} + +@Preview +@Composable +fun HistoryPreview() { + TokushoTheme { + Surface { + HistoryViewContent( + padding = PaddingValues(), + topBarHeightPx = 0f, + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..4e11f41 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt @@ -0,0 +1,36 @@ +package org.xtimms.tokusho.sections.list + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar + +const val LIST_DESTINATION = "list" + +@Composable +fun MangaListView( + sourceName: String, + navigateBack: () -> Unit, +) { + + val scrollState = rememberScrollState() + + ScaffoldWithSmallTopAppBar( + title = sourceName, + navigateBack = navigateBack + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + } + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..a0d8dec --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt @@ -0,0 +1,114 @@ +package org.xtimms.tokusho.sections.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.BackIconButton +import org.xtimms.tokusho.core.screens.EmptyScreen +import org.xtimms.tokusho.ui.theme.TokushoTheme + +const val SEARCH_DESTINATION = "search" + +@Composable +fun SearchHostView( + padding: PaddingValues, + isCompactScreen: Boolean, + navigateBack: () -> Unit, +) { + var query by remember { mutableStateOf("") } + val performSearch = remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = Modifier + .statusBarsPadding() + .padding(top = padding.calculateTopPadding()) + .fillMaxWidth() + ) { + TextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .height(64.dp), + placeholder = { Text(text = stringResource(R.string.search)) }, + leadingIcon = { + if (isCompactScreen) BackIconButton(onClick = navigateBack) + }, + keyboardActions = KeyboardActions( + onSearch = { performSearch.value = true } + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant + ) + ) + SearchView( + query = query, + performSearch = performSearch, + showAsGrid = !isCompactScreen, + contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchView( + query: String, + performSearch: MutableState, + showAsGrid: Boolean, + contentPadding: PaddingValues = PaddingValues(), +) { + val context = LocalContext.current + + EmptyScreen(title = R.string.nothing_here) +} + +@Preview(showBackground = true) +@Composable +fun SearchPreview() { + TokushoTheme { + SearchHostView( + isCompactScreen = true, + padding = PaddingValues(), + navigateBack = {}, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..07860c1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt @@ -0,0 +1,49 @@ +package org.xtimms.tokusho.sections.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.components.SettingItem + +const val SETTINGS_DESTINATION = "settings" + +@Composable +fun SettingsView( + navigateBack: () -> Unit, + navigateToAppearance: () -> Unit, + navigateToAbout: () -> Unit, +) { + ScaffoldWithTopAppBar( + title = stringResource(R.string.settings), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + ) { + item { + SettingItem( + title = stringResource(id = R.string.appearance), + description = stringResource(id = R.string.appearance_page), + icon = Icons.Outlined.Palette, + onClick = navigateToAppearance + ) + } + item { + SettingItem( + title = stringResource(id = R.string.about), + description = stringResource(id = R.string.about_page), + icon = Icons.Outlined.Info, + onClick = navigateToAbout + ) + } + } + } +} \ 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 new file mode 100644 index 0000000..33716f9 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt @@ -0,0 +1,80 @@ +package org.xtimms.tokusho.sections.settings.about + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import org.xtimms.tokusho.App +import org.xtimms.tokusho.App.Companion.packageInfo +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceItem +import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.prefs.AUTO_UPDATE +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.utils.system.toast + +const val ABOUT_DESTINATION = "about" + +const val weblate = "https://hosted.weblate.org/engage/tokusho/" + +@Composable +fun AboutView( + navigateBack: () -> Unit, + navigateToUpdatePage: () -> Unit, +) { + + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + var isAutoUpdateEnabled by remember { mutableStateOf(AppSettings.isAutoUpdateEnabled()) } + + val info = App.getVersionReport() + val versionName = packageInfo.versionName + + ScaffoldWithTopAppBar( + title = stringResource(R.string.about), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + ) { + item { + PreferenceSwitchWithDivider( + title = stringResource(R.string.auto_update), + description = stringResource(R.string.check_for_updates_desc), + icon = Icons.Outlined.Update, + isChecked = isAutoUpdateEnabled, + isSwitchEnabled = true, + onClick = navigateToUpdatePage, + onChecked = { + isAutoUpdateEnabled = !isAutoUpdateEnabled + AppSettings.updateValue(AUTO_UPDATE, isAutoUpdateEnabled) + } + ) + } + item { + PreferenceItem( + title = stringResource(id = R.string.version), + description = versionName, + icon = Icons.Outlined.Info + ) { + clipboardManager.setText(AnnotatedString(info)) + context.toast(R.string.info_copied) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt new file mode 100644 index 0000000..37f4251 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt @@ -0,0 +1,197 @@ +package org.xtimms.tokusho.sections.settings.about + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Update +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceInfo +import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem +import org.xtimms.tokusho.core.components.PreferenceSubtitle +import org.xtimms.tokusho.core.components.PreferenceSwitchWithContainer +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.prefs.AUTO_UPDATE +import org.xtimms.tokusho.core.prefs.AppSettings.updateBoolean +import org.xtimms.tokusho.core.prefs.AppSettings.updateInt +import org.xtimms.tokusho.core.prefs.PRE_RELEASE +import org.xtimms.tokusho.core.prefs.STABLE +import org.xtimms.tokusho.core.prefs.UPDATE_CHANNEL +import org.xtimms.tokusho.core.screens.UpdateDialog +import org.xtimms.tokusho.core.updates.Updater +import org.xtimms.tokusho.utils.lang.booleanState +import org.xtimms.tokusho.utils.lang.intState +import org.xtimms.tokusho.utils.system.suspendToast + +const val UPDATES_DESTINATION = "updates" + +@Composable +fun UpdateView( + navigateBack: () -> Unit, +) { + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var autoUpdate by AUTO_UPDATE.booleanState + var updateChannel by UPDATE_CHANNEL.intState + + var latestRelease by remember { mutableStateOf(Updater.LatestRelease()) } + var showUpdateDialog by remember { mutableStateOf(false) } + + ScaffoldWithTopAppBar( + title = stringResource(R.string.auto_update), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + ) { + item { + PreferenceSwitchWithContainer( + title = stringResource(id = R.string.enable_auto_update), + icon = null, + isChecked = autoUpdate + ) { + autoUpdate = !autoUpdate + AUTO_UPDATE.updateBoolean(autoUpdate) + } + } + item { + PreferenceSubtitle( + modifier = Modifier.padding(horizontal = 4.dp), + text = stringResource(id = R.string.update_channel) + ) + } + item { + PreferenceSingleChoiceItem( + text = stringResource(id = R.string.stable_channel), + selected = updateChannel == STABLE, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) { + updateChannel = STABLE + UPDATE_CHANNEL.updateInt(updateChannel) + } + } + item { + PreferenceSingleChoiceItem( + text = stringResource(id = R.string.pre_release_channel), + selected = updateChannel == PRE_RELEASE, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp) + ) { + updateChannel = PRE_RELEASE + UPDATE_CHANNEL.updateInt(updateChannel) + } + } + 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.check_for_updates + ), + icon = Icons.Outlined.Update, + isLoading = isLoading + ) { + if (!isLoading) + scope.launch { + runCatching { + isLoading = true + withContext(Dispatchers.IO) { + Updater.checkForUpdate()?.let { + latestRelease = it + showUpdateDialog = true + } + ?: context.suspendToast(R.string.app_up_to_date) + } + isLoading = false + } + .onFailure { + it.printStackTrace() + context.suspendToast(R.string.app_update_failed) + isLoading = false + } + } + } + } + HorizontalDivider() + } + item { + PreferenceInfo( + modifier = Modifier + .padding(horizontal = 4.dp), + text = stringResource(id = R.string.update_channel_desc) + ) + } + } + } + if (showUpdateDialog) + UpdateDialog(onDismissRequest = { showUpdateDialog = false }, latestRelease = latestRelease) +} + +@Composable +fun ProgressIndicatorButton( + modifier: Modifier = Modifier, + isLoading: Boolean = false, + text: String, + icon: ImageVector, + onClick: () -> Unit, +) { + FilledTonalButton( + modifier = modifier, + onClick = onClick, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding + ) { + if (isLoading) + Box(modifier = Modifier.size(18.dp)) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + strokeWidth = 3.dp + ) + } + else Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text( + text = text, + modifier = Modifier.padding(start = 8.dp) + ) + } +} \ 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 new file mode 100644 index 0000000..3f53523 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt @@ -0,0 +1,277 @@ +package org.xtimms.tokusho.sections.settings.appearance + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi +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.RowScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +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.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import coil.ImageLoader +import com.google.accompanist.pager.HorizontalPagerIndicator +import com.google.android.material.color.DynamicColors +import org.xtimms.tokusho.LocalDarkTheme +import org.xtimms.tokusho.LocalDynamicColorSwitch +import org.xtimms.tokusho.LocalPaletteStyleIndex +import org.xtimms.tokusho.LocalSeedColor +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.PreferenceSwitchWithDivider +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF +import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON +import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME +import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT +import org.xtimms.tokusho.core.prefs.paletteStyles +import org.xtimms.tokusho.ui.harmonize.hct.Hct +import org.xtimms.tokusho.ui.monet.LocalTonalPalettes +import org.xtimms.tokusho.ui.monet.PaletteStyle +import org.xtimms.tokusho.ui.monet.TonalPalettes +import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes +import org.xtimms.tokusho.ui.monet.a1 +import org.xtimms.tokusho.ui.monet.a2 +import org.xtimms.tokusho.ui.monet.a3 +import org.xtimms.tokusho.utils.system.getLanguageDesc + +const val APPEARANCE_DESTINATION = "appearance" + +val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40.0, 40.0).toInt()) } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AppearanceView( + coil: ImageLoader, + navigateBack: () -> Unit, + navigateToDarkTheme: () -> Unit, + navigateToLanguages: () -> Unit +) { + val image by remember { + mutableIntStateOf( + listOf( + R.drawable.ookami, R.drawable.sample1 + ).random() + ) + } + + ScaffoldWithTopAppBar( + title = stringResource(R.string.appearance), + navigateBack = navigateBack + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + MangaCard( + modifier = Modifier.padding(18.dp), + thumbnailUrl = image + ) + + val pageCount = colorList.size + 1 + val pagerState = rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf( + Color(LocalSeedColor.current) + ).run { if (this == -1) 0 else this }) { + pageCount + } + + HorizontalPager( + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { }, + state = pagerState, + contentPadding = PaddingValues(horizontal = 12.dp) + ) { page -> + if (page < pageCount - 1) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { ColorButtons(colorList[page]) } + } else { + val isSelected = + LocalPaletteStyleIndex.current == STYLE_MONOCHROME && !LocalDynamicColorSwitch.current + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ColorButtonImpl( + modifier = Modifier, + isSelected = { isSelected }, + tonalPalettes = Color.Black.toTonalPalettes(PaletteStyle.Monochrome), + onClick = { + AppSettings.switchDynamicColor(enabled = false) + AppSettings.modifyThemeSeedColor( + Color.Black.toArgb(), STYLE_MONOCHROME + ) + }) + } + } + } + + HorizontalPagerIndicator(pagerState = pagerState, + pageCount = pageCount, + modifier = Modifier + .clearAndSetSemantics { } + .align(Alignment.CenterHorizontally) + .padding(vertical = 12.dp), + activeColor = MaterialTheme.colorScheme.primary, + inactiveColor = MaterialTheme.colorScheme.outlineVariant, + indicatorHeight = 6.dp, + indicatorWidth = 6.dp) + + if (DynamicColors.isDynamicColorAvailable()) { + PreferenceSwitch( + title = stringResource(id = R.string.dynamic_color), + description = stringResource(id = R.string.dynamic_color_desc), + icon = Icons.Outlined.ColorLens, + isChecked = LocalDynamicColorSwitch.current, + onClick = { + AppSettings.switchDynamicColor() + }) + } + val isDarkTheme = LocalDarkTheme.current.isDarkTheme() + PreferenceSwitchWithDivider( + title = stringResource(id = R.string.dark_theme), + icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode, + isChecked = isDarkTheme, + description = LocalDarkTheme.current.getDarkThemeDesc(), + onChecked = { AppSettings.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) }, + onClick = { navigateToDarkTheme() }) + PreferenceItem( + title = stringResource(id = R.string.language), + icon = Icons.Outlined.Language, + description = getLanguageDesc(), + onClick = { navigateToLanguages() }) + } + } +} + +@Composable +fun RowScope.ColorButtons(color: Color) { + paletteStyles.subList(STYLE_TONAL_SPOT, STYLE_MONOCHROME).forEachIndexed { index, style -> + ColorButton(color = color, index = index, tonalStyle = style) + } +} + +@Composable +fun RowScope.ColorButton( + modifier: Modifier = Modifier, + color: Color = Color.Green, + index: Int = 0, + tonalStyle: PaletteStyle = PaletteStyle.TonalSpot, +) { + val tonalPalettes by remember { + mutableStateOf(color.toTonalPalettes(tonalStyle)) + } + val isSelect = + !LocalDynamicColorSwitch.current && LocalSeedColor.current == color.toArgb() && LocalPaletteStyleIndex.current == index + ColorButtonImpl(modifier = modifier, tonalPalettes = tonalPalettes, isSelected = { isSelect }) { + AppSettings.switchDynamicColor(enabled = false) + AppSettings.modifyThemeSeedColor(color.toArgb(), index) + } + +} + +@Composable +fun RowScope.ColorButtonImpl( + modifier: Modifier = Modifier, + isSelected: () -> Boolean = { false }, + tonalPalettes: TonalPalettes, + cardColor: Color = MaterialTheme.colorScheme.surfaceContainer, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + onClick: () -> Unit = {} +) { + + val containerSize by animateDpAsState(targetValue = if (isSelected.invoke()) 28.dp else 0.dp) + val iconSize by animateDpAsState(targetValue = if (isSelected.invoke()) 16.dp else 0.dp) + + Surface( + modifier = modifier + .padding(4.dp) + .sizeIn(maxHeight = 80.dp, maxWidth = 80.dp, minHeight = 64.dp, minWidth = 64.dp) + .weight(1f, false) + .aspectRatio(1f), + shape = RoundedCornerShape(16.dp), + color = cardColor, + onClick = onClick + ) { + CompositionLocalProvider(LocalTonalPalettes provides tonalPalettes) { + val color1 = 80.a1 + val color2 = 90.a2 + val color3 = 60.a3 + Box(Modifier.fillMaxSize()) { + Box(modifier = modifier + .size(48.dp) + .clip(CircleShape) + .drawBehind { drawCircle(color1) } + .align(Alignment.Center)) { + Surface( + color = color2, modifier = Modifier + .align(Alignment.BottomStart) + .size(24.dp) + ) {} + Surface( + color = color3, modifier = Modifier + .align(Alignment.BottomEnd) + .size(24.dp) + ) {} + Box( + modifier = Modifier + .align(Alignment.Center) + .clip(CircleShape) + .size(containerSize) + .drawBehind { drawCircle(containerColor) }, + ) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + modifier = Modifier + .size(iconSize) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt new file mode 100644 index 0000000..e0e210c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt @@ -0,0 +1,88 @@ +package org.xtimms.tokusho.sections.settings.appearance + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.ui.theme.TokushoTheme + +@Composable +fun MangaCard( + modifier: Modifier = Modifier, + title: String = "Ookami to Koushinryou", + author: String = "Hasekura Isuna", + thumbnailUrl: Any = "", + showCancelButton: Boolean = false, + onCancel: () -> Unit = {}, + onClick: () -> Unit = {}, + progress: Float = 75f, +) { + ElevatedCard( + modifier = modifier + .height(136.dp) + .fillMaxWidth(), + onClick = onClick, + shape = MaterialTheme.shapes.small, + ) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Image( + modifier = Modifier + .padding() + .fillMaxHeight() + .clip(MaterialTheme.shapes.small), + painter = painterResource(id = R.drawable.ookami), + contentDescription = null + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Top + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (author != "null") Text( + modifier = Modifier.padding(top = 3.dp), + text = author, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@PreviewLightDark +@Composable +fun MangaCardPreview() { + TokushoTheme { + MangaCard( + thumbnailUrl = "https://spice-and-wolf.com/special/img/visual_january.jpg" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt new file mode 100644 index 0000000..b02292b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt @@ -0,0 +1,77 @@ +package org.xtimms.tokusho.sections.settings.appearance + +import android.os.Build +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Contrast +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.xtimms.tokusho.LocalDarkTheme +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem +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.DarkThemePreference.Companion.FOLLOW_SYSTEM +import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF +import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON + +const val DARK_THEME_DESTINATION = "dark_theme" + +@Composable +fun DarkThemeView( + navigateBack: () -> Unit +) { + + val darkThemePreference = LocalDarkTheme.current + val isHighContrastModeEnabled = darkThemePreference.isHighContrastModeEnabled + + ScaffoldWithTopAppBar( + title = stringResource(R.string.dark_theme), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding)) { + if (Build.VERSION.SDK_INT >= 29) + item { + PreferenceSingleChoiceItem( + text = stringResource(id = R.string.follow_system), + selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM + ) { + AppSettings.modifyDarkThemePreference(FOLLOW_SYSTEM) + } + } + item { + PreferenceSingleChoiceItem( + text = stringResource(id = R.string.on), + selected = darkThemePreference.darkThemeValue == ON + ) { + AppSettings.modifyDarkThemePreference(ON) + } + } + item { + PreferenceSingleChoiceItem( + text = stringResource(id = R.string.off), + selected = darkThemePreference.darkThemeValue == OFF + ) { + AppSettings.modifyDarkThemePreference(OFF) + } + } + item { + PreferenceSubtitle(text = stringResource(R.string.additional_settings)) + } + item { + PreferenceSwitch( + title = stringResource(id = R.string.high_contrast), + icon = Icons.Outlined.Contrast, + isChecked = isHighContrastModeEnabled, + onClick = { + AppSettings.modifyDarkThemePreference(isHighContrastModeEnabled = !isHighContrastModeEnabled) + }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt new file mode 100644 index 0000000..b971360 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt @@ -0,0 +1,200 @@ +package org.xtimms.tokusho.sections.settings.appearance + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +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.tokusho.MainActivity +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem +import org.xtimms.tokusho.core.components.PreferencesHintCard +import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar +import org.xtimms.tokusho.core.prefs.AppSettings +import org.xtimms.tokusho.core.prefs.AppSettings.getLanguageConfiguration +import org.xtimms.tokusho.core.prefs.LANGUAGE +import org.xtimms.tokusho.core.prefs.SYSTEM_DEFAULT +import org.xtimms.tokusho.sections.settings.about.weblate +import org.xtimms.tokusho.ui.theme.TokushoTheme +import org.xtimms.tokusho.utils.system.getLanguageDesc +import org.xtimms.tokusho.utils.system.languageMap + +const val LANGUAGES_DESTINATION = "languages" + +@Composable +fun LanguagesView( + navigateBack: () -> Unit +) { + var language by remember { mutableStateOf(AppSettings.getLanguageNumber()) } + val context = LocalContext.current + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply { + val uri = Uri.fromParts("package", context.packageName, null) + data = uri + } + } else { + Intent() + } + + val isSystemLocaleSettingsAvailable = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.queryIntentActivities( + intent, PackageManager.MATCH_ALL + ).isNotEmpty() + } else { + false + } + LanguageViewImpl( + navigateBack = navigateBack, + languageMap = languageMap, + isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable, + onNavigateToSystemLocaleSettings = { + if (isSystemLocaleSettingsAvailable) { + context.startActivity(intent) + } + }, + selectedLanguage = language, + ) { + language = it + AppSettings.encodeInt(LANGUAGE, language) + MainActivity.setLanguage(getLanguageConfiguration()) + } +} + +@Composable +private fun LanguageViewImpl( + navigateBack: () -> Unit = {}, + languageMap: Map, + isSystemLocaleSettingsAvailable: Boolean = false, + onNavigateToSystemLocaleSettings: () -> Unit, + selectedLanguage: Int, + onLanguageSelected: (Int) -> Unit = {} +) { + + val uriHandler = LocalUriHandler.current + + ScaffoldWithTopAppBar( + title = stringResource(R.string.language), + navigateBack = navigateBack + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + ) { + item { + PreferencesHintCard( + title = stringResource(R.string.translate), + description = stringResource(R.string.translate_desc), + icon = Icons.Outlined.Translate, + ) { uriHandler.openUri(weblate) } + } + item { + PreferenceSingleChoiceItem( + text = stringResource(R.string.follow_system), + selected = selectedLanguage == SYSTEM_DEFAULT, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp) + ) { onLanguageSelected(SYSTEM_DEFAULT) } + } + for (languageData in languageMap) { + item { + PreferenceSingleChoiceItem( + text = getLanguageDesc(languageData.key), + selected = selectedLanguage == languageData.key, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp) + ) { onLanguageSelected(languageData.key) } + } + } + if (isSystemLocaleSettingsAvailable) { + item { + HorizontalDivider() + Surface( + modifier = Modifier.clickable( + onClick = onNavigateToSystemLocaleSettings + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(PaddingValues(horizontal = 12.dp, vertical = 18.dp)), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 10.dp) + ) { + Text( + text = stringResource(R.string.system_settings), + maxLines = 1, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp), + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + modifier = Modifier + .padding(end = 16.dp) + .size(18.dp) + ) + } + } + } + } + } + } +} + +@Preview +@Composable +private fun LanguagePagePreview() { + var language by remember { + mutableIntStateOf(1) + } + val map = buildMap { + repeat(38) { + put(it + 1, "") + } + } + TokushoTheme { + LanguageViewImpl( + languageMap = map, + isSystemLocaleSettingsAvailable = true, + onNavigateToSystemLocaleSettings = { /*TODO*/ }, + selectedLanguage = language + ) { + language = it + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt new file mode 100644 index 0000000..6704d9d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt @@ -0,0 +1,9 @@ +package org.xtimms.tokusho.sections.shelf + +data class ShelfItem( + val libraryManga: ShelfManga, + val downloadCount: Long = -1, + val unreadCount: Long = -1, + val isLocal: Boolean = false, + val sourceLanguage: String = "", +) \ 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 new file mode 100644 index 0000000..f73d8e2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt @@ -0,0 +1,24 @@ +package org.xtimms.tokusho.sections.shelf + +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 new file mode 100644 index 0000000..cda5902 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt @@ -0,0 +1,78 @@ +package org.xtimms.tokusho.sections.shelf + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.screens.EmptyScreen +import org.xtimms.tokusho.utils.system.plus + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ShelfPager( + state: PagerState, + contentPadding: PaddingValues, + hasActiveFilters: Boolean, + searchQuery: String?, + onGlobalSearchClicked: () -> Unit, + getLibraryForPage: (Int) -> List, +) { + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = state, + verticalAlignment = Alignment.Top, + ) { page -> + if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) { + // To make sure only one offscreen page is being composed + return@HorizontalPager + } + val library = getLibraryForPage(page) + + if (library.isEmpty()) { + ShelfPagerEmptyScreen( + searchQuery = searchQuery, + hasActiveFilters = hasActiveFilters, + contentPadding = contentPadding, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + return@HorizontalPager + } + + } +} + +@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 + } + + Column( + modifier = Modifier + .padding(contentPadding + PaddingValues(8.dp)) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + EmptyScreen( + title = msg, + modifier = Modifier.weight(1f), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..8b782c6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt @@ -0,0 +1,54 @@ +package org.xtimms.tokusho.sections.shelf + +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.unit.dp +import androidx.compose.ui.zIndex +import org.xtimms.tokusho.core.components.TabText +import org.xtimms.tokusho.core.model.ShelfCategory +import org.xtimms.tokusho.sections.shelf.ext.visualName + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun ShelfTabs( + categories: List, + pagerState: PagerState, + getNumberOfMangaForCategory: (ShelfCategory) -> Int?, + 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 = category.visualName, + badgeCount = getNumberOfMangaForCategory(category), + ) + }, + unselectedContentColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + + HorizontalDivider() + } +} \ 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 new file mode 100644 index 0000000..ea9deae --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt @@ -0,0 +1,115 @@ +package org.xtimms.tokusho.sections.shelf + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.launch +import org.xtimms.tokusho.core.collapsable +import org.xtimms.tokusho.core.model.ShelfCategory +import org.xtimms.tokusho.ui.theme.TokushoTheme + +const val SHELF_DESTINATION = "stub" + +@Composable +fun ShelfView( + categories: List, + currentPage: () -> Int, + showPageTabs: Boolean, + getNumberOfMangaForCategory: (ShelfCategory) -> Int?, + getLibraryForPage: (Int) -> List, + topBarHeightPx: Float, + padding: PaddingValues, +) { + ShelfViewContent( + categories = categories, + currentPage = currentPage, + showPageTabs = showPageTabs, + getNumberOfMangaForCategory = getNumberOfMangaForCategory, + getLibraryForPage = getLibraryForPage, + topBarHeightPx = topBarHeightPx, + padding = padding + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ShelfViewContent( + categories: List, + currentPage: () -> Int, + showPageTabs: Boolean, + getNumberOfMangaForCategory: (ShelfCategory) -> Int?, + getLibraryForPage: (Int) -> List, + topBarHeightPx: Float, + topBarOffsetY: Animatable = Animatable(0f), + padding: PaddingValues, +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .collapsable( + state = scrollState, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY + ) + .padding(padding) + ) { + val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) } + val pagerState = rememberPagerState(coercedCurrentPage) { categories.size } + val scope = rememberCoroutineScope() + if (showPageTabs && categories.size > 1) { + LaunchedEffect(categories) { + if (categories.size <= pagerState.currentPage) { + pagerState.scrollToPage(categories.size - 1) + } + } + ShelfTabs( + categories = categories, + pagerState = pagerState, + getNumberOfMangaForCategory = getNumberOfMangaForCategory, + ) { scope.launch { pagerState.animateScrollToPage(it) } } + } + + ShelfPager( + state = pagerState, + contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()), + hasActiveFilters = false, + searchQuery = "", + onGlobalSearchClicked = { }, + getLibraryForPage = getLibraryForPage, + ) + } +} + +@Preview +@Composable +fun ShelfPreview() { + val library: ShelfMap = emptyMap() + TokushoTheme { + Surface { + ShelfViewContent( + categories = emptyList(), + currentPage = { 2 }, + showPageTabs = true, + getNumberOfMangaForCategory = { 2 }, + getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() }, + padding = PaddingValues(), + topBarHeightPx = 0f, + ) + } + } +} + +typealias ShelfMap = Map> \ 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 new file mode 100644 index 0000000..6ae9d81 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt @@ -0,0 +1,20 @@ +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/ui/harmonize/hct/Cam16.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Cam16.java new file mode 100644 index 0000000..b74cf98 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Cam16.java @@ -0,0 +1,419 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xtimms.tokusho.ui.harmonize.hct; + +import static java.lang.Math.max; + +import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils; + +/** + * CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex + * code and viewing conditions. + * + *

CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, + * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when + * measuring distances between colors. + * + *

In traditional color spaces, a color can be identified solely by the observer's measurement of + * the color. Color appearance models such as CAM16 also use information about the environment where + * the color was observed, known as the viewing conditions. + * + *

For example, white under the traditional assumption of a midday sun white point is accurately + * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) + */ +public final class Cam16 { + // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. + static final double[][] XYZ_TO_CAM16RGB = { + {0.401288, 0.650173, -0.051461}, + {-0.250268, 1.204414, 0.045854}, + {-0.002079, 0.048952, 0.953127} + }; + + // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. + static final double[][] CAM16RGB_TO_XYZ = { + {1.8620678, -1.0112547, 0.14918678}, + {0.38752654, 0.62144744, -0.00897398}, + {-0.01584150, -0.03412294, 1.0499644} + }; + + // CAM16 color dimensions, see getters for documentation. + private final double hue; + private final double chroma; + private final double j; + private final double q; + private final double m; + private final double s; + + // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. + private final double jstar; + private final double astar; + private final double bstar; + + /** + * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, + * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure + * distances between colors. + */ + double distance(Cam16 other) { + double dJ = getJstar() - other.getJstar(); + double dA = getAstar() - other.getAstar(); + double dB = getBstar() - other.getBstar(); + double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB); + double dE = 1.41 * Math.pow(dEPrime, 0.63); + return dE; + } + + /** Hue in CAM16 */ + public double getHue() { + return hue; + } + + /** Chroma in CAM16 */ + public double getChroma() { + return chroma; + } + + /** Lightness in CAM16 */ + public double getJ() { + return j; + } + + /** + * Brightness in CAM16. + * + *

Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is + * much brighter viewed in sunlight than in indoor light, but it is the lightest object under any + * lighting. + */ + public double getQ() { + return q; + } + + /** + * Colorfulness in CAM16. + * + *

Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much + * more colorful outside than inside, but it has the same chroma in both environments. + */ + public double getM() { + return m; + } + + /** + * Saturation in CAM16. + * + *

Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness + * relative to the color's own brightness, where chroma is colorfulness relative to white. + */ + public double getS() { + return s; + } + + /** Lightness coordinate in CAM16-UCS */ + public double getJstar() { + return jstar; + } + + /** a* coordinate in CAM16-UCS */ + public double getAstar() { + return astar; + } + + /** b* coordinate in CAM16-UCS */ + public double getBstar() { + return bstar; + } + + /** + * All of the CAM16 dimensions can be calculated from 3 of the dimensions, in the following + * combinations: - {j or q} and {c, m, or s} and hue - jstar, astar, bstar Prefer using a static + * method that constructs from 3 of those dimensions. This constructor is intended for those + * methods to use to return all possible dimensions. + * + * @param hue for example, red, orange, yellow, green, etc. + * @param chroma informally, colorfulness / color intensity. like saturation in HSL, except + * perceptually accurate. + * @param j lightness + * @param q brightness; ratio of lightness to white point's lightness + * @param m colorfulness + * @param s saturation; ratio of chroma to white point's chroma + * @param jstar CAM16-UCS J coordinate + * @param astar CAM16-UCS a coordinate + * @param bstar CAM16-UCS b coordinate + */ + private Cam16( + double hue, + double chroma, + double j, + double q, + double m, + double s, + double jstar, + double astar, + double bstar) { + this.hue = hue; + this.chroma = chroma; + this.j = j; + this.q = q; + this.m = m; + this.s = s; + this.jstar = jstar; + this.astar = astar; + this.bstar = bstar; + } + + /** + * Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions. + * + * @param argb ARGB representation of a color. + */ + public static Cam16 fromInt(int argb) { + return fromIntInViewingConditions(argb, ViewingConditions.DEFAULT); + } + + /** + * Create a CAM16 color from a color in defined viewing conditions. + * + * @param argb ARGB representation of a color. + * @param viewingConditions Information about the environment where the color was observed. + */ + // The RGB => XYZ conversion matrix elements are derived scientific constants. While the values + // may differ at runtime due to floating point imprecision, keeping the values the same, and + // accurate, across implementations takes precedence. + @SuppressWarnings("FloatingPointLiteralPrecision") + static Cam16 fromIntInViewingConditions(int argb, ViewingConditions viewingConditions) { + // Transform ARGB int to XYZ + int red = (argb & 0x00ff0000) >> 16; + int green = (argb & 0x0000ff00) >> 8; + int blue = (argb & 0x000000ff); + double redL = ColorUtils.linearized(red); + double greenL = ColorUtils.linearized(green); + double blueL = ColorUtils.linearized(blue); + double x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL; + double y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL; + double z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL; + + // Transform XYZ to 'cone'/'rgb' responses + double[][] matrix = XYZ_TO_CAM16RGB; + double rT = (x * matrix[0][0]) + (y * matrix[0][1]) + (z * matrix[0][2]); + double gT = (x * matrix[1][0]) + (y * matrix[1][1]) + (z * matrix[1][2]); + double bT = (x * matrix[2][0]) + (y * matrix[2][1]) + (z * matrix[2][2]); + + // Discount illuminant + double rD = viewingConditions.getRgbD()[0] * rT; + double gD = viewingConditions.getRgbD()[1] * gT; + double bD = viewingConditions.getRgbD()[2] * bT; + + // Chromatic adaptation + double rAF = Math.pow(viewingConditions.getFl() * Math.abs(rD) / 100.0, 0.42); + double gAF = Math.pow(viewingConditions.getFl() * Math.abs(gD) / 100.0, 0.42); + double bAF = Math.pow(viewingConditions.getFl() * Math.abs(bD) / 100.0, 0.42); + double rA = Math.signum(rD) * 400.0 * rAF / (rAF + 27.13); + double gA = Math.signum(gD) * 400.0 * gAF / (gAF + 27.13); + double bA = Math.signum(bD) * 400.0 * bAF / (bAF + 27.13); + + // redness-greenness + double a = (11.0 * rA + -12.0 * gA + bA) / 11.0; + // yellowness-blueness + double b = (rA + gA - 2.0 * bA) / 9.0; + + // auxiliary components + double u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0; + double p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0; + + // hue + double atan2 = Math.atan2(b, a); + double atanDegrees = Math.toDegrees(atan2); + double hue = + atanDegrees < 0 + ? atanDegrees + 360.0 + : atanDegrees >= 360 ? atanDegrees - 360.0 : atanDegrees; + double hueRadians = Math.toRadians(hue); + + // achromatic response to color + double ac = p2 * viewingConditions.getNbb(); + + // CAM16 lightness and brightness + double j = + 100.0 + * Math.pow( + ac / viewingConditions.getAw(), + viewingConditions.getC() * viewingConditions.getZ()); + double q = + 4.0 + / viewingConditions.getC() + * Math.sqrt(j / 100.0) + * (viewingConditions.getAw() + 4.0) + * viewingConditions.getFlRoot(); + + // CAM16 chroma, colorfulness, and saturation. + double huePrime = (hue < 20.14) ? hue + 360 : hue; + double eHue = 0.25 * (Math.cos(Math.toRadians(huePrime) + 2.0) + 3.8); + double p1 = 50000.0 / 13.0 * eHue * viewingConditions.getNc() * viewingConditions.getNcb(); + double t = p1 * Math.hypot(a, b) / (u + 0.305); + double alpha = + Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73) * Math.pow(t, 0.9); + // CAM16 chroma, colorfulness, saturation + double c = alpha * Math.sqrt(j / 100.0); + double m = c * viewingConditions.getFlRoot(); + double s = + 50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0)); + + // CAM16-UCS components + double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j); + double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m); + double astar = mstar * Math.cos(hueRadians); + double bstar = mstar * Math.sin(hueRadians); + + return new Cam16(hue, c, j, q, m, s, jstar, astar, bstar); + } + + /** + * @param j CAM16 lightness + * @param c CAM16 chroma + * @param h CAM16 hue + */ + static Cam16 fromJch(double j, double c, double h) { + return fromJchInViewingConditions(j, c, h, ViewingConditions.DEFAULT); + } + + /** + * @param j CAM16 lightness + * @param c CAM16 chroma + * @param h CAM16 hue + * @param viewingConditions Information about the environment where the color was observed. + */ + private static Cam16 fromJchInViewingConditions( + double j, double c, double h, ViewingConditions viewingConditions) { + double q = + 4.0 + / viewingConditions.getC() + * Math.sqrt(j / 100.0) + * (viewingConditions.getAw() + 4.0) + * viewingConditions.getFlRoot(); + double m = c * viewingConditions.getFlRoot(); + double alpha = c / Math.sqrt(j / 100.0); + double s = + 50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0)); + + double hueRadians = Math.toRadians(h); + double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j); + double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m); + double astar = mstar * Math.cos(hueRadians); + double bstar = mstar * Math.sin(hueRadians); + return new Cam16(h, c, j, q, m, s, jstar, astar, bstar); + } + + /** + * Create a CAM16 color from CAM16-UCS coordinates. + * + * @param jstar CAM16-UCS lightness. + * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y + * axis. + * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X + * axis. + */ + public static Cam16 fromUcs(double jstar, double astar, double bstar) { + + return fromUcsInViewingConditions(jstar, astar, bstar, ViewingConditions.DEFAULT); + } + + /** + * Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions. + * + * @param jstar CAM16-UCS lightness. + * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y + * axis. + * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X + * axis. + * @param viewingConditions Information about the environment where the color was observed. + */ + public static Cam16 fromUcsInViewingConditions( + double jstar, double astar, double bstar, ViewingConditions viewingConditions) { + + double m = Math.hypot(astar, bstar); + double m2 = Math.expm1(m * 0.0228) / 0.0228; + double c = m2 / viewingConditions.getFlRoot(); + double h = Math.atan2(bstar, astar) * (180.0 / Math.PI); + if (h < 0.0) { + h += 360.0; + } + double j = jstar / (1. - (jstar - 100.) * 0.007); + return fromJchInViewingConditions(j, c, h, viewingConditions); + } + + /** + * ARGB representation of the color. Assumes the color was viewed in default viewing conditions, + * which are near-identical to the default viewing conditions for sRGB. + */ + public int toInt() { + return viewed(ViewingConditions.DEFAULT); + } + + /** + * ARGB representation of the color, in defined viewing conditions. + * + * @param viewingConditions Information about the environment where the color will be viewed. + * @return ARGB representation of color + */ + int viewed(ViewingConditions viewingConditions) { + double alpha = + (getChroma() == 0.0 || getJ() == 0.0) ? 0.0 : getChroma() / Math.sqrt(getJ() / 100.0); + + double t = + Math.pow( + alpha / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), 1.0 / 0.9); + double hRad = Math.toRadians(getHue()); + + double eHue = 0.25 * (Math.cos(hRad + 2.0) + 3.8); + double ac = + viewingConditions.getAw() + * Math.pow(getJ() / 100.0, 1.0 / viewingConditions.getC() / viewingConditions.getZ()); + double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb(); + double p2 = (ac / viewingConditions.getNbb()); + + double hSin = Math.sin(hRad); + double hCos = Math.cos(hRad); + + double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin); + double a = gamma * hCos; + double b = gamma * hSin; + double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0; + double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0; + double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0; + + double rCBase = max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA))); + double rC = + Math.signum(rA) * (100.0 / viewingConditions.getFl()) * Math.pow(rCBase, 1.0 / 0.42); + double gCBase = max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA))); + double gC = + Math.signum(gA) * (100.0 / viewingConditions.getFl()) * Math.pow(gCBase, 1.0 / 0.42); + double bCBase = max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA))); + double bC = + Math.signum(bA) * (100.0 / viewingConditions.getFl()) * Math.pow(bCBase, 1.0 / 0.42); + double rF = rC / viewingConditions.getRgbD()[0]; + double gF = gC / viewingConditions.getRgbD()[1]; + double bF = bC / viewingConditions.getRgbD()[2]; + + double[][] matrix = CAM16RGB_TO_XYZ; + double x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]); + double y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]); + double z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]); + + return ColorUtils.argbFromXyz(x, y, z); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java new file mode 100644 index 0000000..d3e8035 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java @@ -0,0 +1,652 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xtimms.tokusho.ui.harmonize.hct; + +import static java.lang.Math.max; + +import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils; +import org.xtimms.tokusho.ui.harmonize.utils.MathUtils; + +import java.util.ArrayList; + +/** A class that solves the HCT equation. */ +public class CamSolver { + private CamSolver() {} + + static final double[][] SCALED_DISCOUNT_FROM_LINRGB = + new double[][] { + new double[] { + 0.001200833568784504, 0.002389694492170889, 0.0002795742885861124, + }, + new double[] { + 0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398, + }, + new double[] { + 0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076, + }, + }; + + static final double[][] LINRGB_FROM_SCALED_DISCOUNT = + new double[][] { + new double[] { + 1373.2198709594231, -1100.4251190754821, -7.278681089101213, + }, + new double[] { + -271.815969077903, 559.6580465940733, -32.46047482791194, + }, + new double[] { + 1.9622899599665666, -57.173814538844006, 308.7233197812385, + }, + }; + + static final double[] Y_FROM_LINRGB = new double[] {0.2126, 0.7152, 0.0722}; + + static final double[] CRITICAL_PLANES = + new double[] { + 0.015176349177441876, + 0.045529047532325624, + 0.07588174588720938, + 0.10623444424209313, + 0.13658714259697685, + 0.16693984095186062, + 0.19729253930674434, + 0.2276452376616281, + 0.2579979360165119, + 0.28835063437139563, + 0.3188300904430532, + 0.350925934958123, + 0.3848314933096426, + 0.42057480301049466, + 0.458183274052838, + 0.4976837250274023, + 0.5391024159806381, + 0.5824650784040898, + 0.6277969426914107, + 0.6751227633498623, + 0.7244668422128921, + 0.775853049866786, + 0.829304845476233, + 0.8848452951698498, + 0.942497089126609, + 1.0022825574869039, + 1.0642236851973577, + 1.1283421258858297, + 1.1946592148522128, + 1.2631959812511864, + 1.3339731595349034, + 1.407011200216447, + 1.4823302800086415, + 1.5599503113873272, + 1.6398909516233677, + 1.7221716113234105, + 1.8068114625156377, + 1.8938294463134073, + 1.9832442801866852, + 2.075074464868551, + 2.1693382909216234, + 2.2660538449872063, + 2.36523901573795, + 2.4669114995532007, + 2.5710888059345764, + 2.6777882626779785, + 2.7870270208169257, + 2.898822059350997, + 3.0131901897720907, + 3.1301480604002863, + 3.2497121605402226, + 3.3718988244681087, + 3.4967242352587946, + 3.624204428461639, + 3.754355295633311, + 3.887192587735158, + 4.022731918402185, + 4.160988767090289, + 4.301978482107941, + 4.445716283538092, + 4.592217266055746, + 4.741496401646282, + 4.893568542229298, + 5.048448422192488, + 5.20615066083972, + 5.3666897647573375, + 5.5300801301023865, + 5.696336044816294, + 5.865471690767354, + 6.037501145825082, + 6.212438385869475, + 6.390297286737924, + 6.571091626112461, + 6.7548350853498045, + 6.941541251256611, + 7.131223617812143, + 7.323895587840543, + 7.5195704746346665, + 7.7182615035334345, + 7.919981813454504, + 8.124744458384042, + 8.332562408825165, + 8.543448553206703, + 8.757415699253682, + 8.974476575321063, + 9.194643831691977, + 9.417930041841839, + 9.644347703669503, + 9.873909240696694, + 10.106627003236781, + 10.342513269534024, + 10.58158024687427, + 10.8238400726681, + 11.069304815507364, + 11.317986476196008, + 11.569896988756009, + 11.825048221409341, + 12.083451977536606, + 12.345119996613247, + 12.610063955123938, + 12.878295467455942, + 13.149826086772048, + 13.42466730586372, + 13.702830557985108, + 13.984327217668513, + 14.269168601521828, + 14.55736596900856, + 14.848930523210871, + 15.143873411576273, + 15.44220572664832, + 15.743938506781891, + 16.04908273684337, + 16.35764934889634, + 16.66964922287304, + 16.985093187232053, + 17.30399201960269, + 17.62635644741625, + 17.95219714852476, + 18.281524751807332, + 18.614349837764564, + 18.95068293910138, + 19.290534541298456, + 19.633915083172692, + 19.98083495742689, + 20.331304511189067, + 20.685334046541502, + 21.042933821039977, + 21.404114048223256, + 21.76888489811322, + 22.137256497705877, + 22.50923893145328, + 22.884842241736916, + 23.264076429332462, + 23.6469514538663, + 24.033477234264016, + 24.42366364919083, + 24.817520537484558, + 25.21505769858089, + 25.61628489293138, + 26.021211842414342, + 26.429848230738664, + 26.842203703840827, + 27.258287870275353, + 27.678110301598522, + 28.10168053274597, + 28.529008062403893, + 28.96010235337422, + 29.39497283293396, + 29.83362889318845, + 30.276079891419332, + 30.722335150426627, + 31.172403958865512, + 31.62629557157785, + 32.08401920991837, + 32.54558406207592, + 33.010999283389665, + 33.4802739966603, + 33.953417292456834, + 34.430438229418264, + 34.911345834551085, + 35.39614910352207, + 35.88485700094671, + 36.37747846067349, + 36.87402238606382, + 37.37449765026789, + 37.87891309649659, + 38.38727753828926, + 38.89959975977785, + 39.41588851594697, + 39.93615253289054, + 40.460400508064545, + 40.98864111053629, + 41.520882981230194, + 42.05713473317016, + 42.597404951718396, + 43.141702194811224, + 43.6900349931913, + 44.24241185063697, + 44.798841244188324, + 45.35933162437017, + 45.92389141541209, + 46.49252901546552, + 47.065252796817916, + 47.64207110610409, + 48.22299226451468, + 48.808024568002054, + 49.3971762874833, + 49.9904556690408, + 50.587870934119984, + 51.189430279724725, + 51.79514187861014, + 52.40501387947288, + 53.0190544071392, + 53.637271562750364, + 54.259673423945976, + 54.88626804504493, + 55.517063457223934, + 56.15206766869424, + 56.79128866487574, + 57.43473440856916, + 58.08241284012621, + 58.734331877617365, + 59.39049941699807, + 60.05092333227251, + 60.715611475655585, + 61.38457167773311, + 62.057811747619894, + 62.7353394731159, + 63.417162620860914, + 64.10328893648692, + 64.79372614476921, + 65.48848194977529, + 66.18756403501224, + 66.89098006357258, + 67.59873767827808, + 68.31084450182222, + 69.02730813691093, + 69.74813616640164, + 70.47333615344107, + 71.20291564160104, + 71.93688215501312, + 72.67524319850172, + 73.41800625771542, + 74.16517879925733, + 74.9167682708136, + 75.67278210128072, + 76.43322770089146, + 77.1981124613393, + 77.96744375590167, + 78.74122893956174, + 79.51947534912904, + 80.30219030335869, + 81.08938110306934, + 81.88105503125999, + 82.67721935322541, + 83.4778813166706, + 84.28304815182372, + 85.09272707154808, + 85.90692527145302, + 86.72564993000343, + 87.54890820862819, + 88.3767072518277, + 89.2090541872801, + 90.04595612594655, + 90.88742016217518, + 91.73345337380438, + 92.58406282226491, + 93.43925555268066, + 94.29903859396902, + 95.16341895893969, + 96.03240364439274, + 96.9059996312159, + 97.78421388448044, + 98.6670533535366, + 99.55452497210776, + }; + + /** + * Sanitizes a small enough angle in radians. + * + * @param angle An angle in radians; must not deviate too much from 0. + * @return A coterminal angle between 0 and 2pi. + */ + static double sanitizeRadians(double angle) { + return (angle + Math.PI * 8) % (Math.PI * 2); + } + + /** + * Delinearizes an RGB component, returning a floating-point number. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0.0 <= output <= 255.0, color channel converted to regular RGB space + */ + static double trueDelinearized(double rgbComponent) { + double normalized = rgbComponent / 100.0; + double delinearized; + if (normalized <= 0.0031308) { + delinearized = normalized * 12.92; + } else { + delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055; + } + return delinearized * 255.0; + } + + static double chromaticAdaptation(double component) { + double af = Math.pow(Math.abs(component), 0.42); + return MathUtils.signum(component) * 400.0 * af / (af + 27.13); + } + + /** + * Returns the hue of a linear RGB color in CAM16. + * + * @param linrgb The linear RGB coordinates of a color. + * @return The hue of the color in CAM16, in radians. + */ + static double hueOf(double[] linrgb) { + double[] scaledDiscount = MathUtils.matrixMultiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB); + double rA = chromaticAdaptation(scaledDiscount[0]); + double gA = chromaticAdaptation(scaledDiscount[1]); + double bA = chromaticAdaptation(scaledDiscount[2]); + // redness-greenness + double a = (11.0 * rA + -12.0 * gA + bA) / 11.0; + // yellowness-blueness + double b = (rA + gA - 2.0 * bA) / 9.0; + return Math.atan2(b, a); + } + + static boolean areInCyclicOrder(double a, double b, double c) { + double deltaAB = sanitizeRadians(b - a); + double deltaAC = sanitizeRadians(c - a); + return deltaAB < deltaAC; + } + + /** + * Solves the lerp equation. + * + * @param source The starting number. + * @param mid The number in the middle. + * @param target The ending number. + * @return A number t such that lerp(source, target, t) = mid. + */ + static double intercept(double source, double mid, double target) { + return (mid - source) / (target - source); + } + + static double[] lerpPoint(double[] source, double t, double[] target) { + return new double[] { + source[0] + (target[0] - source[0]) * t, + source[1] + (target[1] - source[1]) * t, + source[2] + (target[2] - source[2]) * t, + }; + } + + /** + * Intersects a segment with a plane. + * + * @param source The coordinates of point A. + * @param coordinate The R-, G-, or B-coordinate of the plane. + * @param target The coordinates of point B. + * @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B) + * @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or + * B=coordinate + */ + static double[] setCoordinate(double[] source, double coordinate, double[] target, int axis) { + double t = intercept(source[axis], coordinate, target[axis]); + return lerpPoint(source, t, target); + } + + static boolean isBounded(double x) { + return 0.0 <= x && x <= 100.0; + } + + /** + * Returns the intersections of the plane of constant y with the RGB cube. + * + * @param y The Y value of the plane. + * @return A list of points where the plane intersects with the edges of the RGB cube, in linear + * RGB coordinates. + */ + static ArrayList edgePoints(double y) { + double kR = Y_FROM_LINRGB[0]; + double kG = Y_FROM_LINRGB[1]; + double kB = Y_FROM_LINRGB[2]; + double[][] points = + new double[][] { + new double[] {y / kR, 0.0, 0.0}, + new double[] {(y - 100 * kB) / kR, 0.0, 100.0}, + new double[] {(y - 100 * kG) / kR, 100.0, 0.0}, + new double[] {(y - 100 * kB - 100 * kG) / kR, 100.0, 100.0}, + new double[] {0.0, y / kG, 0.0}, + new double[] {100.0, (y - 100 * kR) / kG, 0.0}, + new double[] {0.0, (y - 100 * kB) / kG, 100.0}, + new double[] {100.0, (y - 100 * kR - 100 * kB) / kG, 100.0}, + new double[] {0.0, 0.0, y / kB}, + new double[] {100.0, 0.0, (y - 100 * kR) / kB}, + new double[] {0.0, 100.0, (y - 100 * kG) / kB}, + new double[] {100.0, 100.0, (y - 100 * kR - 100 * kG) / kB}, + }; + ArrayList ans = new ArrayList<>(); + for (double[] point : points) { + if (isBounded(point[0]) && isBounded(point[1]) && isBounded(point[2])) { + ans.add(point); + } + } + return ans; + } + + /** + * Finds the segment containing the desired color. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the + * segment containing the desired color. + */ + static double[][] bisectToSegment(double y, double targetHue) { + ArrayList vertices = edgePoints(y); + double[] left = vertices.get(0); + double[] right = left; + double leftHue = hueOf(left); + double rightHue = leftHue; + boolean uncut = true; + for (int i = 1; i < vertices.size(); i++) { + double[] mid = vertices.get(i); + double midHue = hueOf(mid); + if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) { + uncut = false; + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid; + rightHue = midHue; + } else { + left = mid; + leftHue = midHue; + } + } + } + return new double[][] {left, right}; + } + + static double[] midpoint(double[] a, double[] b) { + return new double[] { + (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, + }; + } + + static int criticalPlaneBelow(double x) { + return (int) Math.floor(x - 0.5); + } + + static int criticalPlaneAbove(double x) { + return (int) Math.ceil(x - 0.5); + } + + /** + * Finds a color with the given Y and hue on the boundary of the cube. + * + * @param y The Y value of the color. + * @param targetHue The hue of the color. + * @return The desired color, in linear RGB coordinates. + */ + static double[] bisectToLimit(double y, double targetHue) { + double[][] segment = bisectToSegment(y, targetHue); + double[] left = segment[0]; + double leftHue = hueOf(left); + double[] right = segment[1]; + for (int axis = 0; axis < 3; axis++) { + if (left[axis] != right[axis]) { + int lPlane = -1; + int rPlane = 255; + if (left[axis] < right[axis]) { + lPlane = criticalPlaneBelow(trueDelinearized(left[axis])); + rPlane = criticalPlaneAbove(trueDelinearized(right[axis])); + } else { + lPlane = criticalPlaneAbove(trueDelinearized(left[axis])); + rPlane = criticalPlaneBelow(trueDelinearized(right[axis])); + } + for (int i = 0; i < 8; i++) { + if (Math.abs(rPlane - lPlane) <= 1) { + break; + } else { + int mPlane = (lPlane + rPlane) / 2; + double midPlaneCoordinate = CRITICAL_PLANES[mPlane]; + double[] mid = setCoordinate(left, midPlaneCoordinate, right, axis); + double midHue = hueOf(mid); + if (areInCyclicOrder(leftHue, targetHue, midHue)) { + right = mid; + rPlane = mPlane; + } else { + left = mid; + leftHue = midHue; + lPlane = mPlane; + } + } + } + } + } + return midpoint(left, right); + } + + static double inverseChromaticAdaptation(double adapted) { + double adaptedAbs = Math.abs(adapted); + double base = max(0, 27.13 * adaptedAbs / (400.0 - adaptedAbs)); + return MathUtils.signum(adapted) * Math.pow(base, 1.0 / 0.42); + } + + /** + * Finds a color with the given hue, chroma, and Y. + * + * @param hueRadians The desired hue in radians. + * @param chroma The desired chroma. + * @param y The desired Y. + * @return The desired color as a hexadecimal integer, if found; 0 otherwise. + */ + static int findResultByJ(double hueRadians, double chroma, double y) { + // Initial estimate of j. + double j = Math.sqrt(y) * 11.0; + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + ViewingConditions viewingConditions = ViewingConditions.DEFAULT; + double tInnerCoeff = 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73); + double eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8); + double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb(); + double hSin = Math.sin(hueRadians); + double hCos = Math.cos(hueRadians); + for (int iterationRound = 0; iterationRound < 5; iterationRound++) { + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + double jNormalized = j / 100.0; + double alpha = chroma == 0.0 || j == 0.0 ? 0.0 : chroma / Math.sqrt(jNormalized); + double t = Math.pow(alpha * tInnerCoeff, 1.0 / 0.9); + double ac = + viewingConditions.getAw() + * Math.pow(jNormalized, 1.0 / viewingConditions.getC() / viewingConditions.getZ()); + double p2 = ac / viewingConditions.getNbb(); + double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin); + double a = gamma * hCos; + double b = gamma * hSin; + double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0; + double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0; + double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0; + double rCScaled = inverseChromaticAdaptation(rA); + double gCScaled = inverseChromaticAdaptation(gA); + double bCScaled = inverseChromaticAdaptation(bA); + double[] linrgb = + MathUtils.matrixMultiply( + new double[] {rCScaled, gCScaled, bCScaled}, LINRGB_FROM_SCALED_DISCOUNT); + // =========================================================== + // Operations inlined from Cam16 to avoid repeated calculation + // =========================================================== + if (linrgb[0] < 0 || linrgb[1] < 0 || linrgb[2] < 0) { + return 0; + } + double kR = Y_FROM_LINRGB[0]; + double kG = Y_FROM_LINRGB[1]; + double kB = Y_FROM_LINRGB[2]; + double fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2]; + if (fnj <= 0) { + return 0; + } + if (iterationRound == 4 || Math.abs(fnj - y) < 0.002) { + if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) { + return 0; + } + return ColorUtils.argbFromLinrgb(linrgb); + } + // Iterates with Newton method, + // Using 2 * fn(j) / j as the approximation of fn'(j) + j = j - (fnj - y) * j / (2 * fnj); + } + return 0; + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @return A hexadecimal representing the sRGB color. The color has sufficiently close hue, + * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be + * sufficiently close, and chroma will be maximized. + */ + public static int solveToInt(double hueDegrees, double chroma, double lstar) { + if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { + return ColorUtils.argbFromLstar(lstar); + } + hueDegrees = MathUtils.sanitizeDegreesDouble(hueDegrees); + double hueRadians = Math.toRadians(hueDegrees); + double y = ColorUtils.yFromLstar(lstar); + int exactAnswer = findResultByJ(hueRadians, chroma, y); + if (exactAnswer != 0) { + return exactAnswer; + } + double[] linrgb = bisectToLimit(y, hueRadians); + return ColorUtils.argbFromLinrgb(linrgb); + } + + /** + * Finds an sRGB color with the given hue, chroma, and L*, if possible. + * + * @param hueDegrees The desired hue, in degrees. + * @param chroma The desired chroma. + * @param lstar The desired L*. + * @return An CAM16 object representing the sRGB color. The color has sufficiently close hue, + * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be + * sufficiently close, and chroma will be maximized. + */ + public static Cam16 solveToCam(double hueDegrees, double chroma, double lstar) { + return Cam16.fromInt(solveToInt(hueDegrees, chroma, lstar)); + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java new file mode 100644 index 0000000..7b0987c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xtimms.tokusho.ui.harmonize.hct; + +/** + * A color system built using CAM16 hue and chroma, and L* from L*a*b*. + * + *

Using L* creates a link between the color system, contrast, and thus accessibility. Contrast + * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can + * be calculated from Y. + * + *

Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. + * + *

Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A + * difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 + * guarantees a contrast ratio >= 4.5. + */ + +import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils; + +/** + * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color + * measurement system that can also accurately render what colors will appear as in different + * lighting environments. + */ +public final class Hct { + private double hue; + private double chroma; + private double tone; + private int argb; + + /** + * Create an HCT color from hue, chroma, and tone. + * + * @param hue 0 <= hue < 360; invalid values are corrected. + * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than + * the requested chroma. Chroma has a different maximum for any given hue and tone. + * @param tone 0 <= tone <= 100; invalid values are corrected. + * @return HCT representation of a color in default viewing conditions. + */ + public static Hct from(double hue, double chroma, double tone) { + int argb = CamSolver.solveToInt(hue, chroma, tone); + return new Hct(argb); + } + + /** + * Create an HCT color from a color. + * + * @param argb ARGB representation of a color. + * @return HCT representation of a color in default viewing conditions + */ + public static Hct fromInt(int argb) { + return new Hct(argb); + } + + private Hct(int argb) { + setInternalState(argb); + } + + public double getHue() { + return hue; + } + + public double getChroma() { + return chroma; + } + + public double getTone() { + return tone; + } + + public int toInt() { + return argb; + } + + /** + * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any + * given hue and tone. + * + * @param newHue 0 <= newHue < 360; invalid values are corrected. + */ + public void setHue(double newHue) { + setInternalState(CamSolver.solveToInt(newHue, chroma, tone)); + } + + /** + * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for + * any given hue and tone. + * + * @param newChroma 0 <= newChroma < ? + */ + public void setChroma(double newChroma) { + setInternalState(CamSolver.solveToInt(hue, newChroma, tone)); + } + + /** + * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any + * given hue and tone. + * + * @param newTone 0 <= newTone <= 100; invalid valids are corrected. + */ + public void setTone(double newTone) { + setInternalState(CamSolver.solveToInt(hue, chroma, newTone)); + } + + private void setInternalState(int argb) { + this.argb = argb; + Cam16 cam = Cam16.fromInt(argb); + hue = cam.getHue(); + chroma = cam.getChroma(); + this.tone = ColorUtils.lstarFromArgb(argb); + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java new file mode 100644 index 0000000..0f2bddb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java @@ -0,0 +1,197 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xtimms.tokusho.ui.harmonize.hct; + +import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils; +import org.xtimms.tokusho.ui.harmonize.utils.MathUtils; + +/** + * In traditional color spaces, a color can be identified solely by the observer's measurement of + * the color. Color appearance models such as CAM16 also use information about the environment where + * the color was observed, known as the viewing conditions. + * + *

For example, white under the traditional assumption of a midday sun white point is accurately + * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) + * + *

This class caches intermediate values of the CAM16 conversion process that depend only on + * viewing conditions, enabling speed ups. + */ +public final class ViewingConditions { + /** sRGB-like viewing conditions. */ + public static final ViewingConditions DEFAULT = + ViewingConditions.make( + new double[] { + ColorUtils.whitePointD65()[0], + ColorUtils.whitePointD65()[1], + ColorUtils.whitePointD65()[2] + }, + (200.0 / Math.PI * ColorUtils.yFromLstar(50.0) / 100.f), + 50.0, + 2.0, + false); + + private final double aw; + private final double nbb; + private final double ncb; + private final double c; + private final double nc; + private final double n; + private final double[] rgbD; + private final double fl; + private final double flRoot; + private final double z; + + public double getAw() { + return aw; + } + + public double getN() { + return n; + } + + public double getNbb() { + return nbb; + } + + double getNcb() { + return ncb; + } + + double getC() { + return c; + } + + double getNc() { + return nc; + } + + public double[] getRgbD() { + return rgbD; + } + + double getFl() { + return fl; + } + + public double getFlRoot() { + return flRoot; + } + + double getZ() { + return z; + } + + /** + * Create ViewingConditions from a simple, physically relevant, set of parameters. + * + * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day + * afternoon + * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in + * the room where the color is viewed. Can be calculated from lux by multiplying lux by + * 0.0586. default = 11.72, or 200 lux. + * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in + * L*a*b*. default = 50.0 + * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, + * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at + * night. 2.0 means there is no difference between the lighting on the color and around it. + * default = 2.0 + * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, + * such as knowing an apple is still red in green light. default = false, the eye does not + * perform this process on self-luminous objects like displays. + */ + static ViewingConditions make( + double[] whitePoint, + double adaptingLuminance, + double backgroundLstar, + double surround, + boolean discountingIlluminant) { + // Transform white point XYZ to 'cone'/'rgb' responses + double[][] matrix = Cam16.XYZ_TO_CAM16RGB; + double[] xyz = whitePoint; + double rW = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]); + double gW = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]); + double bW = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]); + double f = 0.8 + (surround / 10.0); + double c = + (f >= 0.9) + ? MathUtils.lerp(0.59, 0.69, ((f - 0.9) * 10.0)) + : MathUtils.lerp(0.525, 0.59, ((f - 0.8) * 10.0)); + double d = + discountingIlluminant + ? 1.0 + : f * (1.0 - ((1.0 / 3.6) * Math.exp((-adaptingLuminance - 42.0) / 92.0))); + d = MathUtils.clampDouble(0.0, 1.0, d); + double nc = f; + double[] rgbD = + new double[] { + d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d + }; + double k = 1.0 / (5.0 * adaptingLuminance + 1.0); + double k4 = k * k * k * k; + double k4F = 1.0 - k4; + double fl = (k4 * adaptingLuminance) + (0.1 * k4F * k4F * Math.cbrt(5.0 * adaptingLuminance)); + double n = (ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1]); + double z = 1.48 + Math.sqrt(n); + double nbb = 0.725 / Math.pow(n, 0.2); + double ncb = nbb; + double[] rgbAFactors = + new double[] { + Math.pow(fl * rgbD[0] * rW / 100.0, 0.42), + Math.pow(fl * rgbD[1] * gW / 100.0, 0.42), + Math.pow(fl * rgbD[2] * bW / 100.0, 0.42) + }; + + double[] rgbA = + new double[] { + (400.0 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13), + (400.0 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13), + (400.0 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13) + }; + + double aw = ((2.0 * rgbA[0]) + rgbA[1] + (0.05 * rgbA[2])) * nbb; + return new ViewingConditions(n, aw, nbb, ncb, c, nc, rgbD, fl, Math.pow(fl, 0.25), z); + } + + /** + * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand + * for technical color science terminology, this class would not benefit from documenting them + * individually. A brief overview is available in the CAM16 specification, and a complete overview + * requires a color science textbook, such as Fairchild's Color Appearance Models. + */ + private ViewingConditions( + double n, + double aw, + double nbb, + double ncb, + double c, + double nc, + double[] rgbD, + double fl, + double flRoot, + double z) { + this.n = n; + this.aw = aw; + this.nbb = nbb; + this.ncb = ncb; + this.c = c; + this.nc = nc; + this.rgbD = rgbD; + this.fl = fl; + this.flRoot = flRoot; + this.z = z; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java new file mode 100644 index 0000000..e30f251 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xtimms.tokusho.ui.harmonize.palettes; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +import org.xtimms.tokusho.ui.harmonize.hct.Hct; + +/** + * An intermediate concept between the key color for a UI theme, and a full color scheme. 5 sets of + * tones are generated, all except one use the same hue as the key color, and all vary in chroma. + */ +public final class CorePalette { + public Hct seed; + public TonalPalette a1; + public TonalPalette a2; + public TonalPalette a3; + public TonalPalette n1; + public TonalPalette n2; + public TonalPalette error; + + /** + * Create key tones from a color. + * + * @param argb ARGB representation of a color + */ + public static CorePalette of(int argb) { + return new CorePalette(argb, false); + } + + /** + * Create content key tones from a color. + * + * @param argb ARGB representation of a color + */ + public static CorePalette contentOf(int argb) { + return new CorePalette(argb, true); + } + + private CorePalette(int argb, boolean isContent) { + Hct hct = Hct.fromInt(argb); + double hue = hct.getHue(); + double chroma = hct.getChroma(); + + if (isContent) { + this.a1 = TonalPalette.fromHueAndChroma(hue, chroma); + this.a2 = TonalPalette.fromHueAndChroma(hue, chroma / 3.); + this.a3 = TonalPalette.fromHueAndChroma(hue + 60., chroma / 2.); + this.n1 = TonalPalette.fromHueAndChroma(hue, min(chroma / 12., 4.)); + this.n2 = TonalPalette.fromHueAndChroma(hue, min(chroma / 6., 8.)); + } else { + this.a1 = TonalPalette.fromHueAndChroma(hue, max(48., chroma)); + this.a2 = TonalPalette.fromHueAndChroma(hue, 16.); + this.a3 = TonalPalette.fromHueAndChroma(hue + 60., 24.); + this.n1 = TonalPalette.fromHueAndChroma(hue, 4.); + this.n2 = TonalPalette.fromHueAndChroma(hue, 8.); + } + this.seed = hct; + this.error = TonalPalette.fromHueAndChroma(25, 84.); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java new file mode 100644 index 0000000..9cd39ab --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.xtimms.tokusho.ui.harmonize.palettes; + +import org.xtimms.tokusho.ui.harmonize.hct.Hct; + +import java.util.HashMap; +import java.util.Map; + +/** + * A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone. + */ +public final class TonalPalette { + Map cache; + double hue; + double chroma; + + /** + * Create tones using the HCT hue and chroma from a color. + * + * @param argb ARGB representation of a color + * @return Tones matching that color's hue and chroma. + */ + public static TonalPalette fromInt(int argb) { + Hct hct = Hct.fromInt(argb); + return TonalPalette.fromHueAndChroma(hct.getHue(), hct.getChroma()); + } + + /** + * Create tones from a defined HCT hue and chroma. + * + * @param hue HCT hue + * @param chroma HCT chroma + * @return Tones matching hue and chroma. + */ + public static TonalPalette fromHueAndChroma(double hue, double chroma) { + return new TonalPalette(hue, chroma); + } + + private TonalPalette(double hue, double chroma) { + cache = new HashMap<>(); + this.hue = hue; + this.chroma = chroma; + } + + /** + * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. + * + * @param tone HCT tone, measured from 0 to 100. + * @return ARGB representation of a color with that tone. + */ + // AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923) + @SuppressWarnings("ComputeIfAbsentUseValue") + public int tone(double tone) { + Integer color = cache.get(tone); + if (color == null) { + color = Hct.from(this.hue, this.chroma, tone).toInt(); + cache.put(tone, color); + } + return color; + } + + public int tone(int tone) { + Integer color = cache.get((double)tone); + if (color == null) { + color = Hct.from(this.hue, this.chroma, tone).toInt(); + cache.put((double)tone, color); + } + return color; + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java new file mode 100644 index 0000000..c751dbb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java @@ -0,0 +1,271 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is automatically generated. Do not modify it. + +package org.xtimms.tokusho.ui.harmonize.utils; + +/** + * Color science utilities. + * + *

Utility methods for color science constants and color space conversions that aren't HCT or + * CAM16. + */ +public class ColorUtils { + private ColorUtils() {} + + static final double[][] SRGB_TO_XYZ = + new double[][] { + new double[] {0.41233895, 0.35762064, 0.18051042}, + new double[] {0.2126, 0.7152, 0.0722}, + new double[] {0.01932141, 0.11916382, 0.95034478}, + }; + + static final double[][] XYZ_TO_SRGB = + new double[][] { + new double[] { + 3.2413774792388685, -1.5376652402851851, -0.49885366846268053, + }, + new double[] { + -0.9691452513005321, 1.8758853451067872, 0.04156585616912061, + }, + new double[] { + 0.05562093689691305, -0.20395524564742123, 1.0571799111220335, + }, + }; + + static final double[] WHITE_POINT_D65 = new double[] {95.047, 100.0, 108.883}; + + /** Converts a color from RGB components to ARGB format. */ + public static int argbFromRgb(int red, int green, int blue) { + return (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255); + } + + /** Converts a color from linear RGB components to ARGB format. */ + public static int argbFromLinrgb(double[] linrgb) { + int r = delinearized(linrgb[0]); + int g = delinearized(linrgb[1]); + int b = delinearized(linrgb[2]); + return argbFromRgb(r, g, b); + } + + /** Returns the alpha component of a color in ARGB format. */ + public static int alphaFromArgb(int argb) { + return (argb >> 24) & 255; + } + + /** Returns the red component of a color in ARGB format. */ + public static int redFromArgb(int argb) { + return (argb >> 16) & 255; + } + + /** Returns the green component of a color in ARGB format. */ + public static int greenFromArgb(int argb) { + return (argb >> 8) & 255; + } + + /** Returns the blue component of a color in ARGB format. */ + public static int blueFromArgb(int argb) { + return argb & 255; + } + + /** Returns whether a color in ARGB format is opaque. */ + public static boolean isOpaque(int argb) { + return alphaFromArgb(argb) >= 255; + } + + /** Converts a color from ARGB to XYZ. */ + public static int argbFromXyz(double x, double y, double z) { + double[][] matrix = XYZ_TO_SRGB; + double linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z; + double linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z; + double linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z; + int r = delinearized(linearR); + int g = delinearized(linearG); + int b = delinearized(linearB); + return argbFromRgb(r, g, b); + } + + /** Converts a color from XYZ to ARGB. */ + public static double[] xyzFromArgb(int argb) { + double r = linearized(redFromArgb(argb)); + double g = linearized(greenFromArgb(argb)); + double b = linearized(blueFromArgb(argb)); + return MathUtils.matrixMultiply(new double[] {r, g, b}, SRGB_TO_XYZ); + } + + /** Converts a color represented in Lab color space into an ARGB integer. */ + public static int argbFromLab(double l, double a, double b) { + double[] whitePoint = WHITE_POINT_D65; + double fy = (l + 16.0) / 116.0; + double fx = a / 500.0 + fy; + double fz = fy - b / 200.0; + double xNormalized = labInvf(fx); + double yNormalized = labInvf(fy); + double zNormalized = labInvf(fz); + double x = xNormalized * whitePoint[0]; + double y = yNormalized * whitePoint[1]; + double z = zNormalized * whitePoint[2]; + return argbFromXyz(x, y, z); + } + + /** + * Converts a color from ARGB representation to L*a*b* representation. + * + * @param argb the ARGB representation of a color + * @return a Lab object representing the color + */ + public static double[] labFromArgb(int argb) { + double linearR = linearized(redFromArgb(argb)); + double linearG = linearized(greenFromArgb(argb)); + double linearB = linearized(blueFromArgb(argb)); + double[][] matrix = SRGB_TO_XYZ; + double x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB; + double y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB; + double z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB; + double[] whitePoint = WHITE_POINT_D65; + double xNormalized = x / whitePoint[0]; + double yNormalized = y / whitePoint[1]; + double zNormalized = z / whitePoint[2]; + double fx = labF(xNormalized); + double fy = labF(yNormalized); + double fz = labF(zNormalized); + double l = 116.0 * fy - 16; + double a = 500.0 * (fx - fy); + double b = 200.0 * (fy - fz); + return new double[] {l, a, b}; + } + + /** + * Converts an L* value to an ARGB representation. + * + * @param lstar L* in L*a*b* + * @return ARGB representation of grayscale color with lightness matching L* + */ + public static int argbFromLstar(double lstar) { + double fy = (lstar + 16.0) / 116.0; + double fz = fy; + double fx = fy; + double kappa = 24389.0 / 27.0; + double epsilon = 216.0 / 24389.0; + boolean lExceedsEpsilonKappa = lstar > 8.0; + double y = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa; + boolean cubeExceedEpsilon = fy * fy * fy > epsilon; + double x = cubeExceedEpsilon ? fx * fx * fx : lstar / kappa; + double z = cubeExceedEpsilon ? fz * fz * fz : lstar / kappa; + double[] whitePoint = WHITE_POINT_D65; + return argbFromXyz(x * whitePoint[0], y * whitePoint[1], z * whitePoint[2]); + } + + /** + * Computes the L* value of a color in ARGB representation. + * + * @param argb ARGB representation of a color + * @return L*, from L*a*b*, coordinate of the color + */ + public static double lstarFromArgb(int argb) { + double y = xyzFromArgb(argb)[1] / 100.0; + double e = 216.0 / 24389.0; + if (y <= e) { + return 24389.0 / 27.0 * y; + } else { + double yIntermediate = Math.pow(y, 1.0 / 3.0); + return 116.0 * yIntermediate - 16.0; + } + } + + /** + * Converts an L* value to a Y value. + * + *

L* in L*a*b* and Y in XYZ measure the same quantity, luminance. + * + *

L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a + * logarithmic scale. + * + * @param lstar L* in L*a*b* + * @return Y in XYZ + */ + public static double yFromLstar(double lstar) { + double ke = 8.0; + if (lstar > ke) { + return Math.pow((lstar + 16.0) / 116.0, 3.0) * 100.0; + } else { + return lstar / (24389.0 / 27.0) * 100.0; + } + } + + /** + * Linearizes an RGB component. + * + * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel + * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space + */ + public static double linearized(int rgbComponent) { + double normalized = rgbComponent / 255.0; + if (normalized <= 0.040449936) { + return normalized / 12.92 * 100.0; + } else { + return Math.pow((normalized + 0.055) / 1.055, 2.4) * 100.0; + } + } + + /** + * Delinearizes an RGB component. + * + * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel + * @return 0 <= output <= 255, color channel converted to regular RGB space + */ + public static int delinearized(double rgbComponent) { + double normalized = rgbComponent / 100.0; + double delinearized = 0.0; + if (normalized <= 0.0031308) { + delinearized = normalized * 12.92; + } else { + delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055; + } + return MathUtils.clampInt(0, 255, (int) Math.round(delinearized * 255.0)); + } + + /** + * Returns the standard white point; white on a sunny day. + * + * @return The white point + */ + public static double[] whitePointD65() { + return WHITE_POINT_D65; + } + + static double labF(double t) { + double e = 216.0 / 24389.0; + double kappa = 24389.0 / 27.0; + if (t > e) { + return Math.pow(t, 1.0 / 3.0); + } else { + return (kappa * t + 16) / 116; + } + } + + static double labInvf(double ft) { + double e = 216.0 / 24389.0; + double kappa = 24389.0 / 27.0; + double ft3 = ft * ft * ft; + if (ft3 > e) { + return ft3; + } else { + return (116 * ft - 16) / kappa; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java new file mode 100644 index 0000000..22f156f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java @@ -0,0 +1,133 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is automatically generated. Do not modify it. + +package org.xtimms.tokusho.ui.harmonize.utils; + +/** Utility methods for mathematical operations. */ +public class MathUtils { + private MathUtils() {} + + /** + * The signum function. + * + * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 + */ + public static int signum(double num) { + if (num < 0) { + return -1; + } else if (num == 0) { + return 0; + } else { + return 1; + } + } + + /** + * The linear interpolation function. + * + * @return start if amount = 0 and stop if amount = 1 + */ + public static double lerp(double start, double stop, double amount) { + return (1.0 - amount) * start + amount * stop; + } + + /** + * Clamps an integer between two integers. + * + * @return input when min <= input <= max, and either min or max otherwise. + */ + public static int clampInt(int min, int max, int input) { + if (input < min) { + return min; + } else if (input > max) { + return max; + } + + return input; + } + + /** + * Clamps an integer between two floating-point numbers. + * + * @return input when min <= input <= max, and either min or max otherwise. + */ + public static double clampDouble(double min, double max, double input) { + if (input < min) { + return min; + } else if (input > max) { + return max; + } + + return input; + } + + /** + * Sanitizes a degree measure as an integer. + * + * @return a degree measure between 0 (inclusive) and 360 (exclusive). + */ + public static int sanitizeDegreesInt(int degrees) { + degrees = degrees % 360; + if (degrees < 0) { + degrees = degrees + 360; + } + return degrees; + } + + /** + * Sanitizes a degree measure as a floating-point number. + * + * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). + */ + public static double sanitizeDegreesDouble(double degrees) { + degrees = degrees % 360.0; + if (degrees < 0) { + degrees = degrees + 360.0; + } + return degrees; + } + + /** + * Sign of direction change needed to travel from one angle to another. + * + *

For angles that are 180 degrees apart from each other, both directions have the same travel + * distance, so either direction is shortest. The value 1.0 is returned in this case. + * + * @param from The angle travel starts from, in degrees. + * @param to The angle travel ends at, in degrees. + * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads + * to the shortest travel distance. + */ + public static double rotationDirection(double from, double to) { + double increasingDifference = sanitizeDegreesDouble(to - from); + return increasingDifference <= 180.0 ? 1.0 : -1.0; + } + + /** Distance of two points on a circle, represented using degrees. */ + public static double differenceDegrees(double a, double b) { + return 180.0 - Math.abs(Math.abs(a - b) - 180.0); + } + + /** Multiplies a 1x3 row vector with a 3x3 matrix. */ + public static double[] matrixMultiply(double[] row, double[][] matrix) { + double a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2]; + double b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2]; + double c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2]; + return new double[] {a, b, c}; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt new file mode 100644 index 0000000..4e10e24 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt @@ -0,0 +1,6 @@ +package org.xtimms.tokusho.ui.monet + +data class ColorSpec( + val chroma: (Double) -> Double = { it }, + val hueShift: (Double) -> Double = { 0.0 } +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt new file mode 100644 index 0000000..a3358ef --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt @@ -0,0 +1,101 @@ +package org.xtimms.tokusho.ui.monet + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes + +val LocalTonalPalettes = staticCompositionLocalOf { + Color(0xFF0057C9).toTonalPalettes() +} + +inline val Number.a1: Color + @Composable + get() = LocalTonalPalettes.current accent1 toDouble() + +inline val Number.a2: Color + @Composable get() = LocalTonalPalettes.current accent2 toDouble() + +inline val Number.a3: Color + @Composable get() = LocalTonalPalettes.current accent3 toDouble() + +inline val Number.n1: Color + @Composable get() = LocalTonalPalettes.current neutral1 toDouble() + +inline val Number.n2: Color + @Composable get() = LocalTonalPalettes.current neutral2 toDouble() + +@Composable +fun dynamicColorScheme(isLight: Boolean = !isSystemInDarkTheme()): ColorScheme { + return if (isLight) { + lightColorScheme( + background = 98.n1, + inverseOnSurface = 95.n1, + inversePrimary = 80.a1, + inverseSurface = 20.n1, + onBackground = 10.n1, + onPrimary = 100.a1, + onPrimaryContainer = 10.a1, + onSecondary = 100.a2, + onSecondaryContainer = 10.a2, + onSurface = 10.n1, + onSurfaceVariant = 30.n2, + onTertiary = 100.a3, + onTertiaryContainer = 10.a3, + outline = 50.n2, + outlineVariant = 80.n2, + primary = 40.a1, + primaryContainer = 90.a1, + secondary = 40.a2, + secondaryContainer = 90.a2, + surface = 98.n1, + surfaceVariant = 90.n2, + tertiary = 40.a3, + tertiaryContainer = 90.a3, + surfaceBright = 98.n1, + surfaceDim = 87.n1, + surfaceContainerLowest = 100.n1, + surfaceContainerLow = 96.n1, + surfaceContainer = 94.n1, + surfaceContainerHigh = 92.n1, + surfaceContainerHighest = 90.n1, + ) + } else { + darkColorScheme( + background = 6.n1, + inverseOnSurface = 20.n1, + inversePrimary = 40.a1, + inverseSurface = 90.n1, + onBackground = 90.n1, + onPrimary = 20.a1, + onPrimaryContainer = 90.a1, + onSecondary = 20.a2, + onSecondaryContainer = 90.a2, + onSurface = 90.n1, + onSurfaceVariant = 80.n2, + onTertiary = 20.a3, + onTertiaryContainer = 90.a3, + outline = 60.n2, + outlineVariant = 30.n2, + primary = 80.a1, + primaryContainer = 30.a1, + secondary = 80.a2, + secondaryContainer = 30.a2, + surface = 6.n1, + surfaceVariant = 30.n2, + tertiary = 80.a3, + tertiaryContainer = 30.a3, + surfaceBright = 24.n1, + surfaceDim = 6.n1, + surfaceContainerLowest = 4.n1, + surfaceContainerLow = 10.n1, + surfaceContainer = 12.n1, + surfaceContainerHigh = 17.n1, + surfaceContainerHighest = 22.n1, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt new file mode 100644 index 0000000..89ce1e0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt @@ -0,0 +1,133 @@ +package org.xtimms.tokusho.ui.monet + +class PaletteStyle( + val accent1Spec: ColorSpec, + val accent2Spec: ColorSpec, + val accent3Spec: ColorSpec, + val neutral1Spec: ColorSpec, + val neutral2Spec: ColorSpec +) { + companion object { + private val VibrantSecondaryHueRotation = arrayOf( + 0 to 18, + 41 to 15, + 61 to 10, + 101 to 12, + 131 to 15, + 181 to 18, + 251 to 15, + 301 to 12, + 360 to 12 + ) + private val VibrantTertiaryHueRotation = arrayOf( + 0 to 35, + 41 to 30, + 61 to 20, + 101 to 25, + 131 to 30, + 181 to 35, + 251 to 30, + 301 to 25, + 360 to 25 + ) + private val ExpressiveSecondaryHueRotation = arrayOf( + 0 to 45, + 21 to 95, + 51 to 45, + 121 to 20, + 151 to 45, + 191 to 90, + 271 to 45, + 321 to 45, + 360 to 45 + ) + private val ExpressiveTertiaryHueRotation = arrayOf( + 0 to 120, + 21 to 120, + 51 to 120, + 121 to 45, + 151 to 20, + 191 to 15, + 271 to 20, + 321 to 120, + 360 to 120 + ) + val TonalSpot: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 36.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 16.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 24.0 }) { 60.0 }, + neutral1Spec = ColorSpec({ 6.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 8.0 }) { 0.0 } + ) + val Spritz: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 12.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 8.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 16.0 }) { 30.0 }, + neutral1Spec = ColorSpec({ 2.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 2.0 }) { 0.0 } + ) + val Vibrant: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 48.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 24.0 }) { it.hueRotation(VibrantSecondaryHueRotation) }, + accent3Spec = ColorSpec({ 32.0 }) { it.hueRotation(VibrantTertiaryHueRotation) }, + neutral1Spec = ColorSpec({ 10.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 12.0 }) { 0.0 } + ) + val Expressive: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 40.0 }) { 240.0 }, + accent2Spec = ColorSpec({ 24.0 }) { it.hueRotation(ExpressiveSecondaryHueRotation) }, + accent3Spec = ColorSpec({ 32.0 }) { it.hueRotation(ExpressiveTertiaryHueRotation) }, + neutral1Spec = ColorSpec({ 15.0 }) { 15.0 }, + neutral2Spec = ColorSpec({ 12.0 }) { 15.0 } + ) + val Rainbow: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 48.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 16.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 24.0 }) { -60.0 }, + neutral1Spec = ColorSpec({ 0.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 0.0 }) { 0.0 } + ) + val FruitSalad: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 48.0 }) { -50.0 }, + accent2Spec = ColorSpec({ 36.0 }) { -30.0 }, + accent3Spec = ColorSpec({ 36.0 }) { 0.0 }, + neutral1Spec = ColorSpec({ 10.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 16.0 }) { 0.0 } + ) + val Content: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ it * 1 }) { 0.0 }, + accent2Spec = ColorSpec({ it / 3 }) { 0.0 }, + accent3Spec = ColorSpec({ it * 2 / 3 }) { 60.0 }, + neutral1Spec = ColorSpec({ it / 12 }) { 0.0 }, + neutral2Spec = ColorSpec({ it / 6 }) { 0.0 } + ) + val Monochrome: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 0.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 0.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 0.0 }) { 0.0 }, + neutral1Spec = ColorSpec({ 0.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 0.0 }) { 0.0 }, + ) + + + private fun Double.hueRotation(list: Array>): Double { + var i = 0 + val size = list.size - 2 + if (size >= 0) { + while (true) { + val i2 = i + 1 + val intValue = (list[i2]).first.toFloat() + when { + list[i].first <= this && this < intValue -> { + return (this + list[i].second.toDouble()).mod(360.0) + } + + i == size -> break + else -> i = i2 + } + } + } + return this + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt new file mode 100644 index 0000000..71c7eef --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt @@ -0,0 +1,119 @@ +package org.xtimms.tokusho.ui.monet + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.xtimms.tokusho.ui.harmonize.hct.Hct + +typealias TonalPalette = Map + +class TonalPalettes( + val keyColor: Color, + val style: PaletteStyle = PaletteStyle.TonalSpot, + private val accent1: TonalPalette, + private val accent2: TonalPalette, + private val accent3: TonalPalette, + private val neutral1: TonalPalette, + private val neutral2: TonalPalette +) { + infix fun accent1(tone: Double): Color = accent1.getOrElse(tone) { + keyColor.transform(tone, style.accent1Spec) + } + + infix fun accent2(tone: Double): Color = accent2.getOrElse(tone) { + keyColor.transform(tone, style.accent2Spec) + } + + infix fun accent3(tone: Double): Color = accent3.getOrElse(tone) { + keyColor.transform(tone, style.accent3Spec) + } + + infix fun neutral1(tone: Double): Color = neutral1.getOrElse(tone) { + keyColor.transform(tone, style.neutral1Spec) + } + + infix fun neutral2(tone: Double): Color = neutral2.getOrElse(tone) { + keyColor.transform(tone, style.neutral2Spec) + } + + companion object { + private val M3TonalValues = doubleArrayOf( + 0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 85.0, 90.0, 95.0, 99.0, 100.0 + ) + private val M3SurfaceTonalValues = doubleArrayOf( + 0.0, + 4.0, + 6.0, + 10.0, + 12.0, + 17.0, + 20.0, + 22.0, + 24.0, + 30.0, + 40.0, + 50.0, + 60.0, + 70.0, + 80.0, + 85.0, + 87.0, + 90.0, + 92.0, + 94.0, + 95.0, + 96.0, + 98.0, + 99.0, + 100.0 + ) + + fun Color.toTonalPalettes( + style: PaletteStyle = PaletteStyle.TonalSpot, + tonalValues: DoubleArray = M3TonalValues + ): TonalPalettes = TonalPalettes( + keyColor = this, + style = style, + accent1 = tonalValues.associateWith { transform(it, style.accent1Spec) }, + accent2 = tonalValues.associateWith { transform(it, style.accent2Spec) }, + accent3 = tonalValues.associateWith { transform(it, style.accent3Spec) }, + neutral1 = M3SurfaceTonalValues.associateWith { transform(it, style.neutral1Spec) }, + neutral2 = tonalValues.associateWith { transform(it, style.neutral2Spec) } + ) + + + private fun Color.toTonalPalette( + tonalValues: DoubleArray = M3TonalValues + ): TonalPalette = + tonalValues.associateWith { transform(it, ColorSpec()) } + + + /** + * Convert an existing `ColorScheme` to an MD3 `TonalPalettes` + * + * Notice: This function is `PaletteStyle` independent + * + * @see ColorScheme + * @see TonalPalettes + */ + fun ColorScheme.toTonalPalettes( + tonalValues: DoubleArray = M3TonalValues + ): TonalPalettes = TonalPalettes( + keyColor = primary, + accent1 = primary.toTonalPalette(tonalValues), + accent2 = secondary.toTonalPalette(tonalValues), + accent3 = tertiary.toTonalPalette(tonalValues), + neutral1 = surface.toTonalPalette(M3SurfaceTonalValues), + neutral2 = surfaceVariant.toTonalPalette(tonalValues), + ) + + private fun Color.transform(tone: Double, spec: ColorSpec): Color { + return Color(Hct.fromInt(this.toArgb()).apply { + setTone(tone) + setChroma(spec.chroma(this.chroma)) + setHue(spec.hueShift(this.hue) + this.hue) + }.toInt()) + } + + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..430a8d1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt @@ -0,0 +1,36 @@ +package org.xtimms.shiki.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.xtimms.tokusho.ui.monet.a1 +import org.xtimms.tokusho.ui.monet.a2 +import org.xtimms.tokusho.ui.monet.a3 + +object FixedAccentColors { + val primaryFixed: Color + @Composable get() = 90.a1 + val primaryFixedDim: Color + @Composable get() = 80.a1 + val onPrimaryFixed: Color + @Composable get() = 10.a1 + val onPrimaryFixedVariant: Color + @Composable get() = 30.a1 + val secondaryFixed: Color + @Composable get() = 90.a2 + val secondaryFixedDim: Color + @Composable get() = 80.a2 + val onSecondaryFixed: Color + @Composable get() = 10.a2 + val onSecondaryFixedVariant: Color + @Composable get() = 30.a2 + val tertiaryFixed: Color + @Composable get() = 90.a3 + val tertiaryFixedDim: Color + @Composable get() = 80.a3 + val onTertiaryFixed: Color + @Composable get() = 10.a3 + val onTertiaryFixedVariant: Color + @Composable get() = 30.a3 +} + +const val SEED = 0x0057c9 \ No newline at end of file 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 new file mode 100644 index 0000000..280b1a0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt @@ -0,0 +1,89 @@ +package org.xtimms.tokusho.ui.theme + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.Window +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +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.LocalView +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextDirection +import androidx.core.view.WindowCompat +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.android.material.color.MaterialColors +import org.xtimms.tokusho.ui.monet.dynamicColorScheme + +fun Color.applyOpacity(enabled: Boolean): Color { + return if (enabled) this else this.copy(alpha = 0.62f) +} + +@Composable +fun Color.harmonizeWith(other: Color) = + Color(MaterialColors.harmonize(this.toArgb(), other.toArgb())) + +@Composable +fun Color.harmonizeWithPrimary(): Color = + this.harmonizeWith(other = MaterialTheme.colorScheme.primary) + +private tailrec fun Context.findWindow(): Window? = + when (this) { + is Activity -> window + is ContextWrapper -> baseContext.findWindow() + else -> null + } + +@Composable +fun TokushoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + isHighContrastModeEnabled: Boolean = false, + isDynamicColorEnabled: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = + dynamicColorScheme(!darkTheme).run { + if (isHighContrastModeEnabled && darkTheme) copy( + surface = Color.Black, + background = Color.Black + ) + else this + } + val window = LocalView.current.context.findWindow() + val view = LocalView.current + + window?.let { + WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = darkTheme + } + + rememberSystemUiController(window).setSystemBarsColor(Color.Transparent, !darkTheme, false) + + ProvideTextStyle( + value = LocalTextStyle.current.copy( + lineBreak = LineBreak.Paragraph, + textDirection = TextDirection.Content + ) + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } +} + +@Composable +fun PreviewThemeLight( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = dynamicColorScheme(), + typography = Typography, + 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 new file mode 100644 index 0000000..1e3fbe4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +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 + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) + +val preferenceTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, lineHeight = 24.sp, + lineBreak = LineBreak.Paragraph, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt b/app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt new file mode 100644 index 0000000..ad326c4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt @@ -0,0 +1,18 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import okio.Buffer +import okio.ForwardingSource +import okio.Source + +class CancellableSource( + private val job: Job?, + delegate: Source, +) : ForwardingSource(delegate) { + + override fun read(sink: Buffer, byteCount: Long): Long { + job?.ensureActive() + return super.read(sink, byteCount) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt b/app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt new file mode 100644 index 0000000..f51709b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt @@ -0,0 +1,29 @@ +package org.xtimms.tokusho.utils + +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.Html +import androidx.annotation.WorkerThread +import coil.ImageLoader +import coil.executeBlocking +import coil.request.ImageRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class CoilImageGetter @Inject constructor( + @ApplicationContext private val context: Context, + private val coil: ImageLoader, +) : Html.ImageGetter { + + @WorkerThread + override fun getDrawable(source: String?): Drawable? { + return coil.executeBlocking( + ImageRequest.Builder(context) + .data(source) + .allowHardware(false) + .build(), + ).drawable?.apply { + setBounds(0, 0, intrinsicHeight, intrinsicHeight) + } + } +} \ 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 new file mode 100644 index 0000000..c41d63f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.utils + +import android.content.Context +import android.os.Build +import org.xtimms.tokusho.BuildConfig +import org.xtimms.tokusho.utils.lang.withNonCancellableContext +import org.xtimms.tokusho.utils.lang.withUIContext +import org.xtimms.tokusho.utils.storage.getUriCompat +import org.xtimms.tokusho.utils.system.createFileInCacheDir +import org.xtimms.tokusho.utils.system.toShareIntent +import org.xtimms.tokusho.utils.system.toast + +class CrashLogUtil( + private val context: Context, +) { + + suspend fun dumpLogs() = withNonCancellableContext { + try { + val file = context.createFileInCacheDir("tokusho_crash_logs.txt") + + file.appendText(getDebugInfo() + "\n\n") + + Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}").waitFor() + + val uri = file.getUriCompat(context) + context.startActivity(uri.toShareIntent(context, "text/plain")) + } catch (e: Throwable) { + withUIContext { context.toast("Failed to get logs") } + } + } + + fun getDebugInfo(): String { + return """ + App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY}) + Device brand: ${Build.BRAND} + Device manufacturer: ${Build.MANUFACTURER} + Device name: ${Build.DEVICE} (${Build.PRODUCT}) + Device model: ${Build.MODEL} + """.trimIndent() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt b/app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt new file mode 100644 index 0000000..345d526 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt @@ -0,0 +1,21 @@ +package org.xtimms.tokusho.utils + +import okhttp3.internal.closeQuietly +import okio.Closeable +import okio.Source + +private class ExtraCloseableSource( + private val delegate: Source, + private val extraCloseable: Closeable, +) : Source by delegate { + + override fun close() { + try { + delegate.close() + } finally { + extraCloseable.closeQuietly() + } + } +} + +fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt b/app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt new file mode 100644 index 0000000..c69b750 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt @@ -0,0 +1,39 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.concurrent.atomic.AtomicInteger + +abstract class MediatorStateFlow(initialValue: T) : StateFlow { + + private val delegate = MutableStateFlow(initialValue) + private val collectors = AtomicInteger(0) + + final override val replayCache: List + get() = delegate.replayCache + + final override val value: T + get() = delegate.value + + final override suspend fun collect(collector: FlowCollector): Nothing { + try { + if (collectors.getAndIncrement() == 0) { + onActive() + } + delegate.collect(collector) + } finally { + if (collectors.decrementAndGet() == 0) { + onInactive() + } + } + } + + protected fun publishValue(v: T) { + delegate.value = v + } + + protected abstract fun onActive() + + protected abstract fun onInactive() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt b/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt new file mode 100644 index 0000000..1e62a14 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt @@ -0,0 +1,7 @@ +package org.xtimms.tokusho.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import org.xtimms.tokusho.utils.material.SecondaryItemAlpha + +fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt b/app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt new file mode 100644 index 0000000..c3255cc --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt @@ -0,0 +1,51 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer + +class ProgressResponseBody( + private val delegate: ResponseBody, + private val progressState: MutableStateFlow, +) : ResponseBody() { + + private var bufferedSource: BufferedSource? = null + + override fun close() { + super.close() + delegate.close() + } + + override fun contentLength(): Long = delegate.contentLength() + + override fun contentType(): MediaType? = delegate.contentType() + + override fun source(): BufferedSource { + return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { + bufferedSource = it + } + } + + private class ProgressSource( + delegate: Source, + private val contentLength: Long, + private val progressState: MutableStateFlow, + ) : ForwardingSource(delegate) { + + private var totalBytesRead = 0L + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + if (contentLength > 0) { + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressState.value = (totalBytesRead.toDouble() / contentLength.toDouble()).toFloat() + } + return bytesRead + } + } +} \ 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 new file mode 100644 index 0000000..865b627 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt @@ -0,0 +1,72 @@ +package org.xtimms.tokusho.utils.lang + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +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, +) + +suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = + withContext(NonCancellable, block) + +val processLifecycleScope: LifecycleCoroutineScope + inline get() = ProcessLifecycleOwner.get().lifecycleScope \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt new file mode 100644 index 0000000..122e25a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt @@ -0,0 +1,25 @@ +package org.xtimms.tokusho.utils.lang + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import okio.BufferedSink +import okio.Source +import org.xtimms.tokusho.utils.CancellableSource +import org.xtimms.tokusho.utils.ProgressResponseBody + +fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { + return ProgressResponseBody(this, progressState) +} + +suspend fun Source.cancellable(): Source { + val job = currentCoroutineContext()[Job] + return CancellableSource(job, this) +} + +suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { + writeAll(source.cancellable()) +} \ 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 new file mode 100644 index 0000000..ce71ae3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.utils.lang + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import org.xtimms.tokusho.core.prefs.AppSettings.getBoolean +import org.xtimms.tokusho.core.prefs.AppSettings.getInt +import org.xtimms.tokusho.core.prefs.AppSettings.getString + +inline val String.booleanState + @Composable get() = + remember { mutableStateOf(this.getBoolean()) } + +inline val String.stringState + @Composable get() = + remember { mutableStateOf(this.getString()) } + +inline val String.intState + @Composable get() = remember { + mutableIntStateOf(this.getInt()) + } \ 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 new file mode 100644 index 0000000..b060491 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.utils.lang + +inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { + return if (this.isNullOrEmpty()) defaultValue() else this +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt b/app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt new file mode 100644 index 0000000..07dfe42 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt @@ -0,0 +1,3 @@ +package org.xtimms.tokusho.utils.material + +const val SecondaryItemAlpha = .78f \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt new file mode 100644 index 0000000..d79eec0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt @@ -0,0 +1,16 @@ +package org.xtimms.tokusho.utils.storage + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import org.xtimms.tokusho.BuildConfig +import java.io.File + +/** + * Returns the uri of a file + * + * @param context context of application + */ +fun File.getUriCompat(context: Context): Uri { + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt new file mode 100644 index 0000000..6e0bf1e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt @@ -0,0 +1,23 @@ +package org.xtimms.tokusho.utils.system + +import android.app.ActivityManager +import android.content.Context +import android.content.Context.ACTIVITY_SERVICE +import java.io.File + +val Context.activityManager: ActivityManager? + get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager + +fun Context.createFileInCacheDir(name: String): File { + val file = File(externalCacheDir, name) + if (file.exists()) { + file.delete() + } + file.createNewFile() + + return file +} + +fun Context.isLowRamDevice(): Boolean { + return activityManager?.isLowRamDevice ?: false +} \ 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 new file mode 100644 index 0000000..46b52fb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.utils.system + +import android.content.Context + +fun Context.getFileProvider() = "$packageName.provider" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt new file mode 100644 index 0000000..b200afc --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt @@ -0,0 +1,23 @@ +package org.xtimms.tokusho.utils.system + +import okhttp3.Cookie + +fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> + c.name(name) + c.value(value) + if (persistent) { + c.expiresAt(expiresAt) + } + if (hostOnly) { + c.hostOnlyDomain(domain) + } else { + c.domain(domain) + } + c.path(path) + if (secure) { + c.secure() + } + if (httpOnly) { + c.httpOnly() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt new file mode 100644 index 0000000..2e84730 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt @@ -0,0 +1,30 @@ +package org.xtimms.tokusho.utils.system + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.xtimms.tokusho.R + +fun Uri.toShareIntent(context: Context, type: String = "image/*", message: String? = null): Intent { + val uri = this + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + when (uri.scheme) { + "http", "https" -> { + putExtra(Intent.EXTRA_TEXT, uri.toString()) + } + "content" -> { + message?.let { putExtra(Intent.EXTRA_TEXT, it) } + putExtra(Intent.EXTRA_STREAM, uri) + } + } + clipData = ClipData.newRawUri(null, uri) + setType(type) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + return Intent.createChooser(shareIntent, context.getString(R.string.action_share)).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } +} \ 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 new file mode 100644 index 0000000..301e31c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt @@ -0,0 +1,46 @@ +package org.xtimms.tokusho.utils.system + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.os.LocaleListCompat +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.prefs.AppSettings.getInt +import org.xtimms.tokusho.core.prefs.LANGUAGE +import org.xtimms.tokusho.core.prefs.SYSTEM_DEFAULT +import java.util.Locale + +fun LocaleListCompat.toList(): List = List(size()) { i -> getOrThrow(i) } + +fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() + +private fun getLanguageNumberByCode(languageCode: String) : Int = + languageMap.entries.find { it.value == languageCode }?.key ?: SYSTEM_DEFAULT + +fun getLanguageNumber(): Int { + return if (Build.VERSION.SDK_INT >= 33) + getLanguageNumberByCode( + LocaleListCompat.getAdjustedDefault()[0]?.toLanguageTag().toString() + ) + else LANGUAGE.getInt() +} + +@Composable +fun getLanguageDesc(language: Int = getLanguageNumber()): String { + return stringResource( + when (language) { + ENGLISH -> R.string.la_en_US + RUSSIAN -> R.string.la_ru + else -> R.string.follow_system + } + ) +} + +// Do not modify +private const val ENGLISH = 1 +private const val RUSSIAN = 2 + +// Sorted alphabetically +val languageMap: Map = mapOf( + RUSSIAN to "ru", +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt new file mode 100644 index 0000000..080283d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt @@ -0,0 +1,18 @@ +package org.xtimms.tokusho.utils.system + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities + +val Context.connectivityManager: ConnectivityManager + get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + +fun ConnectivityManager.isOnline(): Boolean { + return activeNetwork?.let { isOnline(it) } ?: false +} + +private fun ConnectivityManager.isOnline(network: Network): Boolean { + val capabilities = getNetworkCapabilities(network) + return capabilities != null && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt new file mode 100644 index 0000000..b66089d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.utils.system + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +@ReadOnlyComposable +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return PaddingValues( + start = calculateStartPadding(layoutDirection) + + other.calculateStartPadding(layoutDirection), + end = calculateEndPadding(layoutDirection) + + other.calculateEndPadding(layoutDirection), + top = calculateTopPadding() + other.calculateTopPadding(), + bottom = calculateBottomPadding() + other.calculateBottomPadding(), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt new file mode 100644 index 0000000..40e228b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt @@ -0,0 +1,18 @@ +package org.xtimms.tokusho.utils.system + +import android.content.SharedPreferences + +fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? { + val stringValue = getString(key, null) ?: return null + return enumClass.enumConstants?.find { + it.name == stringValue + } +} + +fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): E { + return getEnumValue(key, defaultValue.javaClass) ?: defaultValue +} + +fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) { + putString(key, value?.name) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt new file mode 100644 index 0000000..e28b193 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.utils.system + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.xtimms.tokusho.App.Companion.applicationScope + +fun Context.toast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +fun Context.toast(@StringRes stringRes: Int) { + toast(getString(stringRes)) +} + +fun Context.suspendToast(@StringRes stringRes: Int) { + applicationScope.launch(Dispatchers.Main) { + toast(getString(stringRes)) + } +} diff --git a/app/src/main/res/drawable-nodpi/ookami.webp b/app/src/main/res/drawable-nodpi/ookami.webp new file mode 100644 index 0000000000000000000000000000000000000000..60d40e2e5e786ac48ce27a6b73c3fc926736bf65 GIT binary patch literal 37160 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 AH2?qr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-nodpi/sample.webp b/app/src/main/res/drawable-nodpi/sample.webp new file mode 100644 index 0000000000000000000000000000000000000000..245d29fb2b65b10a60ba84a089d5bb4fbece3bed GIT binary patch literal 18146 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 N_79KGS^xk50053_8*Bgo literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-nodpi/sample1.webp b/app/src/main/res/drawable-nodpi/sample1.webp new file mode 100644 index 0000000000000000000000000000000000000000..9e35e638a5312026139b0544cd1c438aff2c9dcd GIT binary patch literal 53616 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8b8484d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,60 @@ + + Tokusho + Settings + Feed + Search + Explore + History + Shelf + Local storage + Bookmarks + Random + Downloads + About + Appearance + Theme, list mode, language + Version, automatic updates + Nothing here + Whoops! + %s ran into an unexpected error. We suggest you share the crash logs in our support channel on Discord. + Share crash logs + Restart the application + Share + Follow system + On + Off + Open settings + Automatic updates + Automatically check for the latest version on GitHub + Version + Info copied to clipboard + Enable auto update + Update channel + Stable + Preview + Check for updates + No new updates + An error occurred while checking updates + Install pre-release builds to preview new features and changes.\n\nThere will be some instability in there versions, so please don\'t hesitate to give us feedback if you experience any problems to help us improve the app for the future. + Language + Dark theme + Dynamic color + Apply colors from wallpapers to the app theme + English (United States) + Русский + Cancel + Additional settings + High contrast dark theme + System settings + Translate + Help translate this app on Hosted Weblate + Update + Dismiss + Added + Progress + Alphabetic + Default + No results found + No matches + No manga in this category + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..df42be7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +