Repository: shizheng233/CopyMangaJava Branch: main Commit: 5d19701a0049 Files: 705 Total size: 1.4 MB Directory structure: gitextract_x0is4721/ ├── .fastRequest/ │ ├── collections/ │ │ └── Root/ │ │ ├── Default Group/ │ │ │ └── directory.json │ │ └── directory.json │ └── config/ │ └── fastRequestCurrentProjectConfig.json ├── .gitattributes ├── .gitignore ├── .gradle/ │ ├── 8.2/ │ │ ├── dependencies-accessors/ │ │ │ └── gc.properties │ │ └── gc.properties │ ├── buildOutputCleanup/ │ │ └── cache.properties │ ├── config.properties │ ├── file-system.probe │ └── vcs-1/ │ └── gc.properties ├── .idea/ │ ├── .gitignore │ ├── assetWizardSettings.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── dbnavigator.xml │ ├── deploymentTargetDropDown.xml │ ├── dictionaries/ │ │ └── ShihCheeng.xml │ ├── fastRequest/ │ │ ├── fastRequestCollection.xml │ │ ├── fastRequestCurrentProjectLocalConfig.xml │ │ └── fastRequestHistoryConfig.xml │ ├── gradle.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── jsonSchemas.xml │ ├── kotlinc.xml │ ├── libraries/ │ │ ├── Gradle__androidx_activity_activity_1_8_2_aar.xml │ │ ├── Gradle__androidx_activity_activity_compose_1_8_2_aar.xml │ │ ├── Gradle__androidx_activity_activity_ktx_1_8_2_aar.xml │ │ ├── Gradle__androidx_annotation_annotation_experimental_1_4_0_aar.xml │ │ ├── Gradle__androidx_annotation_annotation_jvm_1_7_0.xml │ │ ├── Gradle__androidx_appcompat_appcompat_1_6_1_aar.xml │ │ ├── Gradle__androidx_appcompat_appcompat_resources_1_6_1_aar.xml │ │ ├── Gradle__androidx_arch_core_core_common_2_2_0.xml │ │ ├── Gradle__androidx_arch_core_core_runtime_2_2_0_aar.xml │ │ ├── Gradle__androidx_cardview_cardview_1_0_0_aar.xml │ │ ├── Gradle__androidx_collection_collection_jvm_1_4_0.xml │ │ ├── Gradle__androidx_collection_collection_ktx_1_4_0.xml │ │ ├── Gradle__androidx_compose_animation_animation_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_animation_animation_core_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_animation_animation_graphics_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_foundation_foundation_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_foundation_foundation_layout_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_material3_material3_android_1_2_0_rc01_aar.xml │ │ ├── Gradle__androidx_compose_material_material_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_material_material_icons_core_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_material_material_ripple_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_runtime_runtime_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_runtime_runtime_saveable_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_geometry_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_graphics_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_text_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_tooling_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_tooling_data_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_tooling_preview_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_unit_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_compose_ui_ui_util_android_1_6_0_aar.xml │ │ ├── Gradle__androidx_concurrent_concurrent_futures_1_1_0.xml │ │ ├── Gradle__androidx_constraintlayout_constraintlayout_2_1_4_aar.xml │ │ ├── Gradle__androidx_coordinatorlayout_coordinatorlayout_1_1_0_aar.xml │ │ ├── Gradle__androidx_core_core_1_12_0_aar.xml │ │ ├── Gradle__androidx_core_core_ktx_1_12_0_aar.xml │ │ ├── Gradle__androidx_cursoradapter_cursoradapter_1_0_0_aar.xml │ │ ├── Gradle__androidx_customview_customview_1_1_0_aar.xml │ │ ├── Gradle__androidx_databinding_viewbinding_8_2_2_aar.xml │ │ ├── Gradle__androidx_documentfile_documentfile_1_0_0_aar.xml │ │ ├── Gradle__androidx_drawerlayout_drawerlayout_1_1_1_aar.xml │ │ ├── Gradle__androidx_dynamicanimation_dynamicanimation_1_0_0_aar.xml │ │ ├── Gradle__androidx_exifinterface_exifinterface_1_3_3_aar.xml │ │ ├── Gradle__androidx_exifinterface_exifinterface_1_3_6_aar.xml │ │ ├── Gradle__androidx_fragment_fragment_1_6_2_aar.xml │ │ ├── Gradle__androidx_fragment_fragment_ktx_1_6_2_aar.xml │ │ ├── Gradle__androidx_fragment_fragment_testing_1_6_2_aar.xml │ │ ├── Gradle__androidx_fragment_fragment_testing_manifest_1_6_2_aar.xml │ │ ├── Gradle__androidx_hilt_hilt_common_1_1_0.xml │ │ ├── Gradle__androidx_hilt_hilt_navigation_1_1_0_aar.xml │ │ ├── Gradle__androidx_hilt_hilt_navigation_compose_1_1_0_aar.xml │ │ ├── Gradle__androidx_hilt_hilt_work_1_1_0_aar.xml │ │ ├── Gradle__androidx_interpolator_interpolator_1_0_0_aar.xml │ │ ├── Gradle__androidx_legacy_legacy_support_core_utils_1_0_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_common_2_7_0.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_common_java8_2_7_0.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_livedata_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_livedata_core_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_livedata_core_ktx_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_livedata_ktx_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_process_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_runtime_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_runtime_ktx_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_service_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_viewmodel_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_viewmodel_compose_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_viewmodel_ktx_2_7_0_aar.xml │ │ ├── Gradle__androidx_lifecycle_lifecycle_viewmodel_savedstate_2_7_0_aar.xml │ │ ├── Gradle__androidx_loader_loader_1_0_0_aar.xml │ │ ├── Gradle__androidx_localbroadcastmanager_localbroadcastmanager_1_0_0_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_common_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_common_ktx_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_compose_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_fragment_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_fragment_ktx_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_runtime_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_runtime_ktx_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_ui_2_7_6_aar.xml │ │ ├── Gradle__androidx_navigation_navigation_ui_ktx_2_7_6_aar.xml │ │ ├── Gradle__androidx_paging_paging_common_3_2_1.xml │ │ ├── Gradle__androidx_paging_paging_common_ktx_3_2_1.xml │ │ ├── Gradle__androidx_paging_paging_compose_3_2_1_aar.xml │ │ ├── Gradle__androidx_paging_paging_runtime_3_2_1_aar.xml │ │ ├── Gradle__androidx_paging_paging_runtime_ktx_3_2_1_aar.xml │ │ ├── Gradle__androidx_palette_palette_1_0_0_aar.xml │ │ ├── Gradle__androidx_palette_palette_ktx_1_0_0_aar.xml │ │ ├── Gradle__androidx_preference_preference_1_2_1_aar.xml │ │ ├── Gradle__androidx_preference_preference_ktx_1_2_1_aar.xml │ │ ├── Gradle__androidx_print_print_1_0_0_aar.xml │ │ ├── Gradle__androidx_recyclerview_recyclerview_1_2_1_aar.xml │ │ ├── Gradle__androidx_resourceinspection_resourceinspection_annotation_1_0_1.xml │ │ ├── Gradle__androidx_room_room_common_2_6_1.xml │ │ ├── Gradle__androidx_room_room_ktx_2_6_1_aar.xml │ │ ├── Gradle__androidx_room_room_migration_2_6_1.xml │ │ ├── Gradle__androidx_room_room_runtime_2_6_1_aar.xml │ │ ├── Gradle__androidx_room_room_testing_2_6_1_aar.xml │ │ ├── Gradle__androidx_savedstate_savedstate_1_2_1_aar.xml │ │ ├── Gradle__androidx_savedstate_savedstate_ktx_1_2_1_aar.xml │ │ ├── Gradle__androidx_slidingpanelayout_slidingpanelayout_1_2_0_aar.xml │ │ ├── Gradle__androidx_sqlite_sqlite_2_4_0_aar.xml │ │ ├── Gradle__androidx_sqlite_sqlite_framework_2_4_0_aar.xml │ │ ├── Gradle__androidx_startup_startup_runtime_1_1_1_aar.xml │ │ ├── Gradle__androidx_test_annotation_1_0_1_aar.xml │ │ ├── Gradle__androidx_test_core_1_5_0_aar.xml │ │ ├── Gradle__androidx_test_espresso_espresso_core_3_5_1_aar.xml │ │ ├── Gradle__androidx_test_espresso_espresso_idling_resource_3_5_1_aar.xml │ │ ├── Gradle__androidx_test_ext_junit_1_1_5_aar.xml │ │ ├── Gradle__androidx_test_monitor_1_6_0_aar.xml │ │ ├── Gradle__androidx_test_runner_1_5_2_aar.xml │ │ ├── Gradle__androidx_test_services_storage_1_4_2_aar.xml │ │ ├── Gradle__androidx_tracing_tracing_1_0_0_aar.xml │ │ ├── Gradle__androidx_transition_transition_1_2_0_aar.xml │ │ ├── Gradle__androidx_transition_transition_1_4_1_aar.xml │ │ ├── Gradle__androidx_vectordrawable_vectordrawable_1_1_0_aar.xml │ │ ├── Gradle__androidx_vectordrawable_vectordrawable_animated_1_1_0_aar.xml │ │ ├── Gradle__androidx_versionedparcelable_versionedparcelable_1_1_1_aar.xml │ │ ├── Gradle__androidx_viewpager2_viewpager2_1_0_0_aar.xml │ │ ├── Gradle__androidx_viewpager_viewpager_1_0_0_aar.xml │ │ ├── Gradle__androidx_work_work_runtime_2_9_0_aar.xml │ │ ├── Gradle__androidx_work_work_runtime_ktx_2_9_0_aar.xml │ │ ├── Gradle__com_github_KotatsuApp_subsampling_scale_image_view_1b19231b2f_aar.xml │ │ ├── Gradle__com_github_bumptech_glide_annotations_4_15_0.xml │ │ ├── Gradle__com_github_bumptech_glide_disklrucache_4_15_0.xml │ │ ├── Gradle__com_github_bumptech_glide_gifdecoder_4_15_0_aar.xml │ │ ├── Gradle__com_github_bumptech_glide_glide_4_15_0_aar.xml │ │ ├── Gradle__com_github_solkin_disk_lru_cache_1_4_aar.xml │ │ ├── Gradle__com_google_accompanist_accompanist_pager_0_31_3_beta_aar.xml │ │ ├── Gradle__com_google_accompanist_accompanist_pager_indicators_0_31_3_beta_aar.xml │ │ ├── Gradle__com_google_accompanist_accompanist_themeadapter_material3_0_33_1_alpha_aar.xml │ │ ├── Gradle__com_google_android_material_material_1_11_0_aar.xml │ │ ├── Gradle__com_google_code_findbugs_jsr305_3_0_2.xml │ │ ├── Gradle__com_google_code_gson_gson_2_10_1.xml │ │ ├── Gradle__com_google_dagger_dagger_2_48_1.xml │ │ ├── Gradle__com_google_dagger_dagger_lint_aar_2_48_1_aar.xml │ │ ├── Gradle__com_google_dagger_hilt_android_2_48_1_aar.xml │ │ ├── Gradle__com_google_dagger_hilt_core_2_48_1.xml │ │ ├── Gradle__com_google_guava_listenablefuture_1_0.xml │ │ ├── Gradle__com_mikepenz_aboutlibraries_10_5_2_aar.xml │ │ ├── Gradle__com_mikepenz_aboutlibraries_core_android_debug_10_5_2_aar.xml │ │ ├── Gradle__com_squareup_javawriter_2_1_1.xml │ │ ├── Gradle__com_squareup_moshi_moshi_1_15_0.xml │ │ ├── Gradle__com_squareup_moshi_moshi_kotlin_1_15_0.xml │ │ ├── Gradle__com_squareup_okhttp3_logging_interceptor_4_9_3.xml │ │ ├── Gradle__com_squareup_okhttp3_okhttp_4_11_0.xml │ │ ├── Gradle__com_squareup_okio_okio_jvm_3_5_0.xml │ │ ├── Gradle__com_squareup_retrofit2_converter_moshi_2_9_0.xml │ │ ├── Gradle__com_squareup_retrofit2_retrofit_2_9_0.xml │ │ ├── Gradle__dev_chrisbanes_snapper_snapper_0_2_2_aar.xml │ │ ├── Gradle__io_coil_kt_coil_2_4_0_aar.xml │ │ ├── Gradle__io_coil_kt_coil_base_2_4_0_aar.xml │ │ ├── Gradle__io_coil_kt_coil_compose_2_4_0_aar.xml │ │ ├── Gradle__io_coil_kt_coil_compose_base_2_4_0_aar.xml │ │ ├── Gradle__io_github_fornewid_material_motion_compose_core_1_1_0_aar.xml │ │ ├── Gradle__io_github_fornewid_material_motion_compose_navigation_1_1_0_aar.xml │ │ ├── Gradle__javax_inject_javax_inject_1.xml │ │ ├── Gradle__junit_junit_4_13_2.xml │ │ ├── Gradle__org_hamcrest_hamcrest_core_1_3.xml │ │ ├── Gradle__org_hamcrest_hamcrest_integration_1_3.xml │ │ ├── Gradle__org_hamcrest_hamcrest_library_1_3.xml │ │ ├── Gradle__org_jetbrains_annotations_23_0_0.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_android_extensions_runtime_1_9_22.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_parcelize_runtime_1_9_22.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_reflect_1_8_21.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_reflect_1_8_22.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_stdlib_1_9_22.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk7_1_9_0.xml │ │ ├── Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk8_1_9_0.xml │ │ ├── Gradle__org_jetbrains_kotlinx_kotlinx_coroutines_android_1_7_1.xml │ │ └── Gradle__org_jetbrains_kotlinx_kotlinx_coroutines_core_jvm_1_7_1.xml │ ├── migrations.xml │ ├── misc.xml │ ├── modules/ │ │ ├── CopyMangaJava.iml │ │ └── app/ │ │ ├── CopyMangaJava.app.androidTest.iml │ │ ├── CopyMangaJava.app.iml │ │ ├── CopyMangaJava.app.main.iml │ │ └── CopyMangaJava.app.unitTest.iml │ ├── modules.xml │ ├── navEditor.xml │ ├── other.xml │ ├── render.experimental.xml │ └── vcs.xml ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── debug/ │ │ ├── app-debug.apk │ │ └── output-metadata.json │ ├── proguard-rules.pro │ ├── release/ │ │ ├── app-release.apk │ │ └── output-metadata.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── shicheeng/ │ │ └── copymanga/ │ │ └── ExampleInstrumentedTest.java │ ├── debug/ │ │ └── res/ │ │ ├── drawable-anydpi/ │ │ │ ├── ic_explore_outline.xml │ │ │ ├── ic_setting_outline.xml │ │ │ ├── ic_swith_horiz.xml │ │ │ ├── ic_swith_vert.xml │ │ │ └── ic_trend_up.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ └── ic_copy.xml │ │ └── values/ │ │ └── ic_copy_background.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── shicheeng/ │ │ │ └── copymanga/ │ │ │ ├── CrashHandler.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MangaReaderActivity.kt │ │ │ ├── MyApp.kt │ │ │ ├── app/ │ │ │ │ ├── AppAttachCompatActivity.kt │ │ │ │ └── BaseFragment.kt │ │ │ ├── dao/ │ │ │ │ ├── MangaLoginDao.kt │ │ │ │ ├── MangeLocalHistoryDao.kt │ │ │ │ └── SearchHistoryDao.kt │ │ │ ├── data/ │ │ │ │ ├── BannerList.java │ │ │ │ ├── ChipTextBean.kt │ │ │ │ ├── DataBannerBean.java │ │ │ │ ├── ListBeanManga.kt │ │ │ │ ├── LocalManga.kt │ │ │ │ ├── MainPageDataModel.kt │ │ │ │ ├── MainTopicDataModel.kt │ │ │ │ ├── MangaGenernal.kt │ │ │ │ ├── MangaHistoryDataModel.kt │ │ │ │ ├── MangaInfoChapterDataBean.kt │ │ │ │ ├── MangaReadInformation.kt │ │ │ │ ├── MangaReaderPage.kt │ │ │ │ ├── MangaSortBean.java │ │ │ │ ├── PersonalDataModel.kt │ │ │ │ ├── ReaderDataModels.kt │ │ │ │ ├── ReaderState.kt │ │ │ │ ├── UpdateMetadata.kt │ │ │ │ ├── authormanga/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── AuthorMangaItem.kt │ │ │ │ │ ├── AuthorsMangaDataModel.kt │ │ │ │ │ ├── FreeType.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── chapter/ │ │ │ │ │ ├── Chapter.kt │ │ │ │ │ ├── ChapterDataModel.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── collect/ │ │ │ │ │ └── ComicCollectDataModel.kt │ │ │ │ ├── commentpush/ │ │ │ │ │ └── CommentPushDataModel.kt │ │ │ │ ├── downloadmodel/ │ │ │ │ │ └── DownloadUiDataModel.kt │ │ │ │ ├── finished/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── FinishedMangaDataModel.kt │ │ │ │ │ ├── FreeType.kt │ │ │ │ │ ├── Item.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── info/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── Comic.kt │ │ │ │ │ ├── Default.kt │ │ │ │ │ ├── FreeType.kt │ │ │ │ │ ├── Groups.kt │ │ │ │ │ ├── LastChapter.kt │ │ │ │ │ ├── MangaInfoDataModel.kt │ │ │ │ │ ├── Reclass.kt │ │ │ │ │ ├── Region.kt │ │ │ │ │ ├── Restrict.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── Status.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── local/ │ │ │ │ │ ├── Chapter.kt │ │ │ │ │ └── LocalSavableMangaModel.kt │ │ │ │ ├── lofininfo/ │ │ │ │ │ ├── LoginInfoDataModel.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── login/ │ │ │ │ │ ├── LocalLoginDataModel.kt │ │ │ │ │ ├── LoginDataModel.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── logininfoshort/ │ │ │ │ │ ├── Gender.kt │ │ │ │ │ ├── GenderX.kt │ │ │ │ │ ├── Info.kt │ │ │ │ │ ├── LoginInfoShortDataModel.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── mangacomment/ │ │ │ │ │ ├── MangaCommentDataModel.kt │ │ │ │ │ ├── MangaCommentListItem.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── mangacontent/ │ │ │ │ │ ├── Chapter.kt │ │ │ │ │ ├── Comic.kt │ │ │ │ │ ├── Content.kt │ │ │ │ │ ├── MangaContentDataModel.kt │ │ │ │ │ ├── Restrict.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── newsest/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── Comic.kt │ │ │ │ │ ├── MangaBlock.kt │ │ │ │ │ ├── NewestListDataModel.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── rank/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── Comic.kt │ │ │ │ │ ├── Item.kt │ │ │ │ │ ├── RankDataModel.kt │ │ │ │ │ └── Results.kt │ │ │ │ ├── recommend/ │ │ │ │ │ └── RecommendDataModel.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── SearchDataModel.kt │ │ │ │ │ └── SearchResultDataModel.kt │ │ │ │ ├── searchhelpword/ │ │ │ │ │ └── SearchTermWordDataModel.kt │ │ │ │ ├── searchhistory/ │ │ │ │ │ └── SearchHistory.kt │ │ │ │ ├── searchrecommend/ │ │ │ │ │ ├── Data.kt │ │ │ │ │ └── SearchRecommendDataModel.kt │ │ │ │ ├── sorttag/ │ │ │ │ │ ├── Ordering.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── SortTagsDataModel.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── topicalllist/ │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── Series.kt │ │ │ │ │ ├── TopicAllListDataModel.kt │ │ │ │ │ └── TopicAllListItem.kt │ │ │ │ ├── topicinfo/ │ │ │ │ │ ├── Last.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── Series.kt │ │ │ │ │ └── TopicInfoDataModelX.kt │ │ │ │ ├── topiclist/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ ├── TopicItem.kt │ │ │ │ │ └── TopicListDataModel.kt │ │ │ │ ├── webbookshelf/ │ │ │ │ │ ├── Author.kt │ │ │ │ │ ├── Browse.kt │ │ │ │ │ ├── Comic.kt │ │ │ │ │ ├── LastBrowse.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ ├── WebBookshelf.kt │ │ │ │ │ └── WebBookshelfItem.kt │ │ │ │ ├── webcomichistory/ │ │ │ │ │ ├── Browse.kt │ │ │ │ │ ├── Results.kt │ │ │ │ │ └── WebComicHistory.kt │ │ │ │ └── webhistory/ │ │ │ │ ├── Author.kt │ │ │ │ ├── Comic.kt │ │ │ │ ├── Results.kt │ │ │ │ ├── WebHistoryDataModel.kt │ │ │ │ └── WebHistoryItem.kt │ │ │ ├── database/ │ │ │ │ ├── MangaHistoryDataBase.kt │ │ │ │ ├── MangaLoginDatabase.kt │ │ │ │ └── StringToBeanConvert.kt │ │ │ ├── dialog/ │ │ │ │ ├── ConfigPagerSheet.kt │ │ │ │ └── ListPreferenceXTheme.kt │ │ │ ├── domin/ │ │ │ │ ├── CopyMangaApi.kt │ │ │ │ └── DownloadFileDetectUtil.kt │ │ │ ├── error/ │ │ │ │ ├── ContinuationCallCallback.kt │ │ │ │ ├── DownloadErrorException.kt │ │ │ │ ├── EmptyJsonArray.kt │ │ │ │ └── ErrorActivity.kt │ │ │ ├── fm/ │ │ │ │ ├── delegate/ │ │ │ │ │ └── IdlingDelegate.kt │ │ │ │ ├── domain/ │ │ │ │ │ ├── ChapterLoader.kt │ │ │ │ │ ├── ChapterPages.kt │ │ │ │ │ ├── PageHolderDelegate.kt │ │ │ │ │ ├── PagerCache.kt │ │ │ │ │ └── PagerLoader.kt │ │ │ │ └── reader/ │ │ │ │ ├── BaseReader.kt │ │ │ │ ├── BaseReaderAdapter.kt │ │ │ │ ├── BaseReaderViewHolder.kt │ │ │ │ ├── MangaLoader.kt │ │ │ │ ├── ReaderManager.kt │ │ │ │ ├── noraml/ │ │ │ │ │ ├── PageSliderFormatter.kt │ │ │ │ │ ├── ReaderPageAdapter.kt │ │ │ │ │ ├── ReaderPageFragment.kt │ │ │ │ │ └── ReaderPageViewHolder.kt │ │ │ │ ├── standard/ │ │ │ │ │ └── ReaderPagerStandardFragment.kt │ │ │ │ └── webtoon/ │ │ │ │ ├── WebtoonFrameLayout.kt │ │ │ │ ├── WebtoonImageView.kt │ │ │ │ ├── WebtoonLayoutManager.kt │ │ │ │ ├── WebtoonReaderAdapter.kt │ │ │ │ ├── WebtoonReaderFragment.kt │ │ │ │ ├── WebtoonReaderViewHolder.kt │ │ │ │ ├── WebtoonRecyclerView.kt │ │ │ │ └── WebtoonScalingFrame.kt │ │ │ ├── json/ │ │ │ │ ├── MainBannerJson.kt │ │ │ │ ├── MangaSortJson.kt │ │ │ │ └── UpdateMetaDataJson.kt │ │ │ ├── modula/ │ │ │ │ ├── CopyMangaApiModula.kt │ │ │ │ ├── LoginRoomModula.kt │ │ │ │ ├── OkhttpProvider.kt │ │ │ │ ├── RoomModula.kt │ │ │ │ └── WorkerModula.kt │ │ │ ├── pagingsource/ │ │ │ │ ├── AuthorsMangaPagingSource.kt │ │ │ │ ├── ComicCommentPagingSource.kt │ │ │ │ ├── ExplorePagingSource.kt │ │ │ │ ├── FinishedPagingSource.kt │ │ │ │ ├── HotPagingSource.kt │ │ │ │ ├── MangaTopicListPagingSource.kt │ │ │ │ ├── NewestPagingSource.kt │ │ │ │ ├── RankPagingSource.kt │ │ │ │ ├── RecommendPagingSource.kt │ │ │ │ ├── SearchResultPagingSource.kt │ │ │ │ ├── TopicDetailListPagingSource.kt │ │ │ │ ├── WebHistoryPagingSource.kt │ │ │ │ └── WebShelfPagingSource.kt │ │ │ ├── resposity/ │ │ │ │ ├── AuthorsMangaRepository.kt │ │ │ │ ├── ComicCommentRepository.kt │ │ │ │ ├── LoginDetailRepository.kt │ │ │ │ ├── LoginRepository.kt │ │ │ │ ├── LoginTokenRepository.kt │ │ │ │ ├── MangaFilterRepository.kt │ │ │ │ ├── MangaFinishedRepository.kt │ │ │ │ ├── MangaHistoryRepository.kt │ │ │ │ ├── MangaHotRepository.kt │ │ │ │ ├── MangaInfoRepository.kt │ │ │ │ ├── MangaMainPageRepository.kt │ │ │ │ ├── MangaNewestRepository.kt │ │ │ │ ├── MangaRankRepository.kt │ │ │ │ ├── MangaRecommendRepository.kt │ │ │ │ ├── MangaSearchRepository.kt │ │ │ │ ├── MangaTopicDetailRepository.kt │ │ │ │ ├── WebHistoryRepository.kt │ │ │ │ └── WebShelfRepository.kt │ │ │ ├── server/ │ │ │ │ ├── DownloadState.kt │ │ │ │ ├── download/ │ │ │ │ │ ├── domin/ │ │ │ │ │ │ ├── DownloadState.kt │ │ │ │ │ │ ├── DownloaderLocalIndex.kt │ │ │ │ │ │ ├── DownloaderOutPutter.kt │ │ │ │ │ │ ├── PausingHandle.kt │ │ │ │ │ │ └── PausingHandler.kt │ │ │ │ │ └── woker/ │ │ │ │ │ ├── DownloadNotificationFactory.kt │ │ │ │ │ └── DownloadedWorker.kt │ │ │ │ └── work/ │ │ │ │ ├── DetectMangaUpdateWork.kt │ │ │ │ └── IDetectManga.kt │ │ │ ├── ui/ │ │ │ │ ├── screen/ │ │ │ │ │ ├── MainNavigation.kt │ │ │ │ │ ├── Router.kt │ │ │ │ │ ├── authorsmanga/ │ │ │ │ │ │ └── AuthorsMangaScreen.kt │ │ │ │ │ ├── comment/ │ │ │ │ │ │ ├── CommentItem.kt │ │ │ │ │ │ ├── CommentScreen.kt │ │ │ │ │ │ └── CommentSendBar.kt │ │ │ │ │ ├── compoents/ │ │ │ │ │ │ ├── CircleLoadingButton.kt │ │ │ │ │ │ ├── Components.kt │ │ │ │ │ │ ├── ComposeExt.kt │ │ │ │ │ │ ├── EasyCover.kt │ │ │ │ │ │ ├── EmptyDataScreen.kt │ │ │ │ │ │ ├── LoadingScreen.kt │ │ │ │ │ │ ├── MangaCover.kt │ │ │ │ │ │ ├── RefreshLayout.kt │ │ │ │ │ │ ├── SaveStatePager.kt │ │ │ │ │ │ ├── VerticalFastScroller.kt │ │ │ │ │ │ └── pullrefresh/ │ │ │ │ │ │ ├── CircularProgressPainter.kt │ │ │ │ │ │ ├── Slingshot.kt │ │ │ │ │ │ ├── SwipeRefresh.kt │ │ │ │ │ │ └── SwipeRefreshIndicator.kt │ │ │ │ │ ├── download/ │ │ │ │ │ │ ├── DownloadScreen.kt │ │ │ │ │ │ ├── DownloadScreenComponents.kt │ │ │ │ │ │ ├── DownloadScreenViewModel.kt │ │ │ │ │ │ ├── EmptyScreen.kt │ │ │ │ │ │ └── StateButton.kt │ │ │ │ │ ├── downloaded/ │ │ │ │ │ │ ├── Downloaded.kt │ │ │ │ │ │ └── DownloadedResolveDialog.kt │ │ │ │ │ ├── error/ │ │ │ │ │ │ └── ErrorScreen.kt │ │ │ │ │ ├── history/ │ │ │ │ │ │ ├── History.kt │ │ │ │ │ │ ├── local/ │ │ │ │ │ │ │ ├── HistoryComponents.kt │ │ │ │ │ │ │ └── LocalHistoryScreen.kt │ │ │ │ │ │ └── web/ │ │ │ │ │ │ ├── WebHistoryScreen.kt │ │ │ │ │ │ └── WebHistoryViewModel.kt │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── CommonListComponent.kt │ │ │ │ │ │ ├── NewestScreen.kt │ │ │ │ │ │ └── RecommendScreen.kt │ │ │ │ │ ├── login/ │ │ │ │ │ │ ├── LoginScreen.kt │ │ │ │ │ │ ├── LoginViewModel.kt │ │ │ │ │ │ └── loginlist/ │ │ │ │ │ │ ├── LoginPeronsalItem.kt │ │ │ │ │ │ └── LoginPersonListScreen.kt │ │ │ │ │ ├── main/ │ │ │ │ │ │ ├── MainScreen.kt │ │ │ │ │ │ ├── MainScreenViewModel.kt │ │ │ │ │ │ ├── explore/ │ │ │ │ │ │ │ ├── ExploreComponents.kt │ │ │ │ │ │ │ ├── ExploreFilterBottomSheet.kt │ │ │ │ │ │ │ ├── ExploreMangaFilter.kt │ │ │ │ │ │ │ └── ExploreScreen.kt │ │ │ │ │ │ ├── home/ │ │ │ │ │ │ │ ├── BannerComponents.kt │ │ │ │ │ │ │ ├── BannerState.kt │ │ │ │ │ │ │ ├── HomeComponents.kt │ │ │ │ │ │ │ ├── HomeLeaderBoard.kt │ │ │ │ │ │ │ ├── HomeListKey.kt │ │ │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ │ │ ├── HomeTopicCard.kt │ │ │ │ │ │ │ └── search/ │ │ │ │ │ │ │ ├── Search.kt │ │ │ │ │ │ │ └── SearchComponent.kt │ │ │ │ │ │ ├── leaderboard/ │ │ │ │ │ │ │ ├── LeaderBoard.kt │ │ │ │ │ │ │ └── LeaderboardComponents.kt │ │ │ │ │ │ ├── personal/ │ │ │ │ │ │ │ ├── PersonalHeaderView.kt │ │ │ │ │ │ │ ├── PersonalScreen.kt │ │ │ │ │ │ │ ├── PersonalToken.kt │ │ │ │ │ │ │ └── personaldetail/ │ │ │ │ │ │ │ ├── PersonalDetail.kt │ │ │ │ │ │ │ └── PersonalDetailTwoRowText.kt │ │ │ │ │ │ └── subscribe/ │ │ │ │ │ │ └── SubscribedScreen.kt │ │ │ │ │ ├── manga/ │ │ │ │ │ │ ├── MangaChapterComponents.kt │ │ │ │ │ │ ├── MangaDetailBottomBar.kt │ │ │ │ │ │ ├── MangaDetailBottomSelector.kt │ │ │ │ │ │ ├── MangaDetailHeader.kt │ │ │ │ │ │ ├── MangaDetailKey.kt │ │ │ │ │ │ ├── MangaDetailScreen.kt │ │ │ │ │ │ ├── MangaDetailSummary.kt │ │ │ │ │ │ └── MangaDetailVerticalIcon.kt │ │ │ │ │ ├── search/ │ │ │ │ │ │ └── SearchResultScreen.kt │ │ │ │ │ ├── setting/ │ │ │ │ │ │ ├── Setting.kt │ │ │ │ │ │ ├── SettingComponents.kt │ │ │ │ │ │ ├── SettingDialog.kt │ │ │ │ │ │ ├── SettingPref.kt │ │ │ │ │ │ ├── SettingViewModel.kt │ │ │ │ │ │ ├── about/ │ │ │ │ │ │ │ ├── About.kt │ │ │ │ │ │ │ └── AboutDatas.kt │ │ │ │ │ │ └── worker/ │ │ │ │ │ │ └── Worker.kt │ │ │ │ │ ├── topiclist/ │ │ │ │ │ │ ├── TopicListScreen.kt │ │ │ │ │ │ └── TopicListVIewModel.kt │ │ │ │ │ ├── topics/ │ │ │ │ │ │ ├── TopicComicItem.kt │ │ │ │ │ │ ├── TopicHeader.kt │ │ │ │ │ │ ├── TopicHeaderKeys.kt │ │ │ │ │ │ ├── TopicScreen.kt │ │ │ │ │ │ └── TopicViewModel.kt │ │ │ │ │ └── webshelf/ │ │ │ │ │ └── WebShelfScreen.kt │ │ │ │ └── theme/ │ │ │ │ ├── Color.kt │ │ │ │ ├── ElevationTokens.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Typo.kt │ │ │ ├── util/ │ │ │ │ ├── FileCacheUtils.java │ │ │ │ ├── FirstSnapHelper.kt │ │ │ │ ├── FunctionUtils.kt │ │ │ │ ├── GestureHelper.kt │ │ │ │ ├── JsonObjectExtra.kt │ │ │ │ ├── KeyWordSwap.java │ │ │ │ ├── OkhttpHelper.kt │ │ │ │ ├── ProcessLifecycle.kt │ │ │ │ ├── ReaderSliderAttach.kt │ │ │ │ ├── RetainedLifecycleCoroutineScope.kt │ │ │ │ ├── RetryableFlow.kt │ │ │ │ ├── RunCatchingExtra.kt │ │ │ │ ├── SharedPreferenceExtra.kt │ │ │ │ ├── StateFlowExtra.kt │ │ │ │ ├── ThemeChanger.kt │ │ │ │ ├── Throttler.kt │ │ │ │ ├── ViewExtra.kt │ │ │ │ ├── file/ │ │ │ │ │ └── FileSequence.kt │ │ │ │ ├── iterator/ │ │ │ │ │ ├── CloseableIterator.kt │ │ │ │ │ └── MappingIterator.kt │ │ │ │ └── progress/ │ │ │ │ └── TimeLeftEstimator.kt │ │ │ ├── view/ │ │ │ │ ├── ExpandSelectionBar.kt │ │ │ │ ├── HeadLineView.java │ │ │ │ ├── MyRecyclerView.kt │ │ │ │ ├── SummaryText.kt │ │ │ │ ├── TransitionTextview.kt │ │ │ │ ├── control/ │ │ │ │ │ └── ReaderControl.kt │ │ │ │ └── list/ │ │ │ │ └── SpaceItem.kt │ │ │ └── viewmodel/ │ │ │ ├── AuthorMangaViewModel.kt │ │ │ ├── CommentViewModel.kt │ │ │ ├── DownloadViewModel.kt │ │ │ ├── ExploreMangaViewModel.kt │ │ │ ├── HistoryViewModel.kt │ │ │ ├── HomeViewModel.kt │ │ │ ├── LoginPersonalListViewModel.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── MangaHotListViewModel.kt │ │ │ ├── MangaInfoViewModel.kt │ │ │ ├── MangaNewestListViewModel.kt │ │ │ ├── MangaRecommendListViewModel.kt │ │ │ ├── PersonalDetailViewModel.kt │ │ │ ├── PersonalViewModel.kt │ │ │ ├── RankViewModel.kt │ │ │ ├── ReaderViewModel.kt │ │ │ ├── RootViewModel.kt │ │ │ ├── SearchResultViewModel.kt │ │ │ ├── SearchViewModel.kt │ │ │ ├── SubscribedViewModel.kt │ │ │ └── WebShelfViewModel.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── apache_svgrepo_com.xml │ │ │ ├── baseline_add_24.xml │ │ │ ├── baseline_cached_24.xml │ │ │ ├── baseline_close_24.xml │ │ │ ├── baseline_comment_24.xml │ │ │ ├── baseline_content_cut_24.xml │ │ │ ├── baseline_delete_24.xml │ │ │ ├── baseline_delete_outline_24.xml │ │ │ ├── baseline_done_24.xml │ │ │ ├── baseline_explore_24.xml │ │ │ ├── baseline_format_list_bulleted_24.xml │ │ │ ├── baseline_history_24.xml │ │ │ ├── baseline_insert_chart_24.xml │ │ │ ├── baseline_insert_chart_outlined_24.xml │ │ │ ├── baseline_library_add_24.xml │ │ │ ├── baseline_library_add_check_24.xml │ │ │ ├── baseline_pause_24.xml │ │ │ ├── baseline_person_24.xml │ │ │ ├── baseline_play_arrow_24.xml │ │ │ ├── baseline_remove_done_24.xml │ │ │ ├── baseline_replay_24.xml │ │ │ ├── baseline_rss_feed_24.xml │ │ │ ├── baseline_security_update_24.xml │ │ │ ├── baseline_send_24.xml │ │ │ ├── baseline_visibility_24.xml │ │ │ ├── baseline_visibility_off_24.xml │ │ │ ├── baseline_warning_amber_24.xml │ │ │ ├── baseline_webhook_24.xml │ │ │ ├── ic_arrow_avd.xml │ │ │ ├── ic_baseline_home_24.xml │ │ │ ├── ic_baseline_hot.xml │ │ │ ├── ic_baseline_loop.xml │ │ │ ├── ic_baseline_region.xml │ │ │ ├── ic_done_all.xml │ │ │ ├── ic_home_selector.xml │ │ │ ├── ic_outline_home_24.xml │ │ │ ├── ic_outline_page.xml │ │ │ ├── ic_skip_next_24.xml │ │ │ ├── ic_skip_previous_24.xml │ │ │ ├── iconmonstr_github_5.xml │ │ │ ├── iconmonstr_rss_feed_baseline.xml │ │ │ ├── iconmonstr_rss_feed_outline.xml │ │ │ ├── legal_license_mit_svgrepo_com.xml │ │ │ ├── open_source_fill_svgrepo_com.xml │ │ │ ├── outline_auto_mode_24.xml │ │ │ ├── outline_cell_wifi_24.xml │ │ │ ├── outline_chrome_reader_mode_24.xml │ │ │ ├── outline_clean_hands_24.xml │ │ │ ├── outline_cleaning_services_24.xml │ │ │ ├── outline_cloud_24.xml │ │ │ ├── outline_comment_24.xml │ │ │ ├── outline_contrast_24.xml │ │ │ ├── outline_do_not_disturb_24.xml │ │ │ ├── outline_download_24.xml │ │ │ ├── outline_download_for_offline_24.xml │ │ │ ├── outline_expand_circle_down_24.xml │ │ │ ├── outline_file_download_24.xml │ │ │ ├── outline_home_24.xml │ │ │ ├── outline_input_24.xml │ │ │ ├── outline_library_books_24.xml │ │ │ ├── outline_switch_access_shortcut_24.xml │ │ │ ├── outline_timer_24.xml │ │ │ ├── outline_touch_app_24.xml │ │ │ ├── outline_update_disabled_24.xml │ │ │ ├── outline_wifi_lock_24.xml │ │ │ ├── outline_work_history_24.xml │ │ │ ├── transition_text_background.xml │ │ │ ├── undraw_arrow.xml │ │ │ ├── undraw_drink_coffee.xml │ │ │ ├── undraw_login_re.xml │ │ │ ├── undraw_no_data_re_kwbl.xml │ │ │ └── undraw_personal_file_re.xml │ │ ├── drawable-anydpi/ │ │ │ ├── ic_arrow_back.xml │ │ │ ├── ic_explore_outline.xml │ │ │ ├── ic_look_more.xml │ │ │ ├── ic_manga_info_main.xml │ │ │ ├── ic_manga_search.xml │ │ │ ├── ic_person_center.xml │ │ │ ├── ic_setting_outline.xml │ │ │ ├── ic_swith_horiz.xml │ │ │ ├── ic_swith_vert.xml │ │ │ └── ic_trend_up.xml │ │ ├── layout/ │ │ │ ├── activity_manga_reader.xml │ │ │ ├── fragment_dialog_download_info.xml │ │ │ ├── fragment_reader_normal.xml │ │ │ ├── fragment_reader_webtoon.xml │ │ │ ├── item_last_page.xml │ │ │ ├── item_page.xml │ │ │ ├── item_page_webtoon.xml │ │ │ ├── layout_error.xml │ │ │ ├── layout_image_load.xml │ │ │ ├── layout_setting_wraning_region.xml │ │ │ ├── layout_widget_switch_pref.xml │ │ │ ├── magan_select_bar.xml │ │ │ ├── manga_headline_1.xml │ │ │ └── sheet_manga_model_switcher.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ └── ic_copy.xml │ │ ├── resources.properties │ │ ├── values/ │ │ │ ├── about_libraries.xml │ │ │ ├── colors.xml │ │ │ ├── day_night_01.xml │ │ │ ├── expand_view_declare.xml │ │ │ ├── head_line_view.xml │ │ │ ├── ic_copy_background.xml │ │ │ ├── ic_launcher_copy_background.xml │ │ │ ├── inch.xml │ │ │ ├── string_array.xml │ │ │ ├── strings.xml │ │ │ ├── style.xml │ │ │ ├── theme_overlays.xml │ │ │ └── themes.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ ├── theme_overlays.xml │ │ │ └── themes.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── values-zh-rHK/ │ │ │ └── strings.xml │ │ └── values-zh-rTW/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── shicheeng/ │ └── copymanga/ │ └── ExampleUnitTest.java ├── build.gradle ├── gradle/ │ └── wrapper/ │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── local.properties ├── resources.properties └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .fastRequest/collections/Root/Default Group/directory.json ================================================ { "description":"", "directory":"~.fastRequest~collections~Root~Default Group", "filePath":"~.fastRequest~collections~Root~Default Group~", "groupId":"1", "id":"1", "name":"Default Group", "type":1 } ================================================ FILE: .fastRequest/collections/Root/directory.json ================================================ { "description":"", "directory":"~.fastRequest~collections~Root", "filePath":"~.fastRequest~collections~Root~", "groupId":"-1", "id":"0", "name":"Root", "type":1 } ================================================ FILE: .fastRequest/config/fastRequestCurrentProjectConfig.json ================================================ { "dataList":[ { "hostGroup":[ { "env":"App", "url":"api.co" } ], "name":"App" } ], "envList":[ "App" ], "headerList":[], "postScript":"", "preScript":"", "projectList":[ "App" ], "syncModel":{ "branch":"master", "domain":"https://github.com", "enabled":false, "namingPolicy":"byDoc", "owner":"", "repo":"", "repoUrl":"", "syncAfterRun":false, "token":"", "type":"github" }, "urlEncodedKeyValueList":[], "urlParamsKeyValueList":[], "urlSuffix":"" } ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto ================================================ FILE: .gitignore ================================================ # 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* replay_pid* ================================================ FILE: .gradle/8.2/dependencies-accessors/gc.properties ================================================ ================================================ FILE: .gradle/8.2/gc.properties ================================================ ================================================ FILE: .gradle/buildOutputCleanup/cache.properties ================================================ #Mon Jan 08 18:49:54 CST 2024 gradle.version=8.2 ================================================ FILE: .gradle/config.properties ================================================ #Mon Dec 11 18:47:37 CST 2023 java.home=C\:\\Program Files\\Android\\Android Studio\\jbr ================================================ FILE: .gradle/vcs-1/gc.properties ================================================ ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: .idea/assetWizardSettings.xml ================================================ ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/dbnavigator.xml ================================================ ================================================ FILE: .idea/deploymentTargetDropDown.xml ================================================ ================================================ FILE: .idea/dictionaries/ShihCheeng.xml ================================================ ================================================ FILE: .idea/fastRequest/fastRequestCollection.xml ================================================ ================================================ FILE: .idea/fastRequest/fastRequestCurrentProjectLocalConfig.xml ================================================ ================================================ FILE: .idea/fastRequest/fastRequestHistoryConfig.xml ================================================ ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: .idea/jsonSchemas.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_activity_activity_1_8_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_activity_activity_compose_1_8_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_activity_activity_ktx_1_8_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_annotation_annotation_experimental_1_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_annotation_annotation_jvm_1_7_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_appcompat_appcompat_1_6_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_appcompat_appcompat_resources_1_6_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_arch_core_core_common_2_2_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_arch_core_core_runtime_2_2_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_cardview_cardview_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_collection_collection_jvm_1_4_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_collection_collection_ktx_1_4_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_animation_animation_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_animation_animation_core_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_animation_animation_graphics_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_foundation_foundation_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_foundation_foundation_layout_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_material3_material3_android_1_2_0_rc01_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_material_material_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_material_material_icons_core_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_material_material_ripple_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_runtime_runtime_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_runtime_runtime_saveable_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_geometry_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_graphics_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_text_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_tooling_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_tooling_data_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_tooling_preview_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_unit_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_compose_ui_ui_util_android_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_concurrent_concurrent_futures_1_1_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_constraintlayout_constraintlayout_2_1_4_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_coordinatorlayout_coordinatorlayout_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_core_core_1_12_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_core_core_ktx_1_12_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_cursoradapter_cursoradapter_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_customview_customview_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_databinding_viewbinding_8_2_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_documentfile_documentfile_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_drawerlayout_drawerlayout_1_1_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_dynamicanimation_dynamicanimation_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_exifinterface_exifinterface_1_3_3_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_exifinterface_exifinterface_1_3_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_fragment_fragment_1_6_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_fragment_fragment_ktx_1_6_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_fragment_fragment_testing_1_6_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_fragment_fragment_testing_manifest_1_6_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_hilt_hilt_common_1_1_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_hilt_hilt_navigation_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_hilt_hilt_navigation_compose_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_hilt_hilt_work_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_interpolator_interpolator_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_legacy_legacy_support_core_utils_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_2_7_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_common_java8_2_7_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_core_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_core_ktx_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_livedata_ktx_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_process_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_runtime_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_runtime_ktx_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_service_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_compose_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_ktx_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_lifecycle_lifecycle_viewmodel_savedstate_2_7_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_loader_loader_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_localbroadcastmanager_localbroadcastmanager_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_common_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_common_ktx_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_compose_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_fragment_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_fragment_ktx_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_runtime_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_runtime_ktx_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_ui_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_navigation_navigation_ui_ktx_2_7_6_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_paging_paging_common_3_2_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_paging_paging_common_ktx_3_2_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_paging_paging_compose_3_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_paging_paging_runtime_3_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_paging_paging_runtime_ktx_3_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_palette_palette_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_palette_palette_ktx_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_preference_preference_1_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_preference_preference_ktx_1_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_print_print_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_recyclerview_recyclerview_1_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_resourceinspection_resourceinspection_annotation_1_0_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_room_room_common_2_6_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_room_room_ktx_2_6_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_room_room_migration_2_6_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_room_room_runtime_2_6_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_room_room_testing_2_6_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_savedstate_savedstate_1_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_savedstate_savedstate_ktx_1_2_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_slidingpanelayout_slidingpanelayout_1_2_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_sqlite_sqlite_2_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_sqlite_sqlite_framework_2_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_startup_startup_runtime_1_1_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_annotation_1_0_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_core_1_5_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_espresso_espresso_core_3_5_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_espresso_espresso_idling_resource_3_5_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_ext_junit_1_1_5_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_monitor_1_6_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_runner_1_5_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_test_services_storage_1_4_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_tracing_tracing_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_transition_transition_1_2_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_transition_transition_1_4_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_vectordrawable_vectordrawable_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_vectordrawable_vectordrawable_animated_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_versionedparcelable_versionedparcelable_1_1_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_viewpager2_viewpager2_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_viewpager_viewpager_1_0_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_work_work_runtime_2_9_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__androidx_work_work_runtime_ktx_2_9_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_github_KotatsuApp_subsampling_scale_image_view_1b19231b2f_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_github_bumptech_glide_annotations_4_15_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_github_bumptech_glide_disklrucache_4_15_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_github_bumptech_glide_gifdecoder_4_15_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_github_bumptech_glide_glide_4_15_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_github_solkin_disk_lru_cache_1_4_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_accompanist_accompanist_pager_0_31_3_beta_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_accompanist_accompanist_pager_indicators_0_31_3_beta_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_accompanist_accompanist_themeadapter_material3_0_33_1_alpha_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_android_material_material_1_11_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_code_findbugs_jsr305_3_0_2.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_code_gson_gson_2_10_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_dagger_dagger_2_48_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_dagger_dagger_lint_aar_2_48_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_dagger_hilt_android_2_48_1_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_dagger_hilt_core_2_48_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_google_guava_listenablefuture_1_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_mikepenz_aboutlibraries_10_5_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_mikepenz_aboutlibraries_core_android_debug_10_5_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_javawriter_2_1_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_moshi_moshi_1_15_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_moshi_moshi_kotlin_1_15_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_okhttp3_logging_interceptor_4_9_3.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_okhttp3_okhttp_4_11_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_okio_okio_jvm_3_5_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_retrofit2_converter_moshi_2_9_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__com_squareup_retrofit2_retrofit_2_9_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__dev_chrisbanes_snapper_snapper_0_2_2_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__io_coil_kt_coil_2_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__io_coil_kt_coil_base_2_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__io_coil_kt_coil_compose_2_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__io_coil_kt_coil_compose_base_2_4_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__io_github_fornewid_material_motion_compose_core_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__io_github_fornewid_material_motion_compose_navigation_1_1_0_aar.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__javax_inject_javax_inject_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__junit_junit_4_13_2.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_hamcrest_hamcrest_core_1_3.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_hamcrest_hamcrest_integration_1_3.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_hamcrest_hamcrest_library_1_3.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_annotations_23_0_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_android_extensions_runtime_1_9_22.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_parcelize_runtime_1_9_22.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_reflect_1_8_21.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_reflect_1_8_22.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_1_9_22.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk7_1_9_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlin_kotlin_stdlib_jdk8_1_9_0.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlinx_kotlinx_coroutines_android_1_7_1.xml ================================================ ================================================ FILE: .idea/libraries/Gradle__org_jetbrains_kotlinx_kotlinx_coroutines_core_jvm_1_7_1.xml ================================================ ================================================ FILE: .idea/migrations.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/modules/CopyMangaJava.iml ================================================ ================================================ FILE: .idea/modules/app/CopyMangaJava.app.androidTest.iml ================================================ :app:main $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-parcelize-compiler/1.9.22/222c989e288e9a99c8579de44a0fe61230ddbfc5/kotlin-parcelize-compiler-1.9.22.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/com.google.devtools.ksp/symbol-processing/1.9.22-1.0.16/70b84dfb906092d3995f07ab28b24390f7547ae3/symbol-processing-1.9.22-1.0.16.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compiler-embeddable/1.9.22/9cd4dc7773cf2a99ecd961a88fbbc9a2da3fb5e1/kotlin-compiler-embeddable-1.9.22.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/com.google.devtools.ksp/symbol-processing-api/1.9.22-1.0.16/ca56365e65965a5b9b4a6da4597221e22604ff34/symbol-processing-api-1.9.22-1.0.16.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk8/1.9.0/e000bd084353d84c9e888f6fb341dc1f5b79d948/kotlin-stdlib-jdk8-1.9.0.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.9.0/f320478990d05e0cfaadd74f9619fd6027adbf37/kotlin-stdlib-jdk7-1.9.0.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.9.22/d6c44cd08d8f3f9bece8101216dbe6553365c6e3/kotlin-stdlib-1.9.22.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/1.9.22/f8139a46fc677ec9badc49ae954392f4f5e7e7c7/kotlin-script-runtime-1.9.22.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-reflect/1.6.10/1cbe9c92c12a94eea200d23c2bbaedaf3daf5132/kotlin-reflect-1.6.10.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-daemon-embeddable/1.9.22/20e2c5df715f3240c765cfc222530e2796542021/kotlin-daemon-embeddable-1.9.22.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains.intellij.deps/trove4j/1.0.20200330/3afb14d5f9ceb459d724e907a21145e8ff394f02/trove4j-1.0.20200330.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar $USER_HOME$/.gradle/caches/modules-2/files-2.1/androidx.compose.compiler/compiler/1.5.8/b6b82a47735d855ae7050d1aa2f026dfeedaf635/compiler-1.5.8.jar plugin:androidx.compose.plugins.idea:enabled=true plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true ================================================ FILE: .idea/modules/app/CopyMangaJava.app.iml ================================================ ================================================ FILE: .idea/modules/app/CopyMangaJava.app.main.iml ================================================ ================================================ FILE: .idea/modules/app/CopyMangaJava.app.unitTest.iml ================================================ :app:main ================================================ FILE: .idea/modules.xml ================================================ ================================================ FILE: .idea/navEditor.xml ================================================ ================================================ FILE: .idea/other.xml ================================================ ================================================ FILE: .idea/render.experimental.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 shizheng233 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # CopyMangaJava ![GitHub all releases](https://img.shields.io/github/downloads/shizheng233/CopyMangaJava/total?label=%E4%B8%8B%E8%BD%BD&style=flat-square) ![Android Version Require](https://img.shields.io/badge/%E5%AE%89%E5%8D%93%E7%89%88%E6%9C%AC-%3E%3D%209.0-brightgreen?style=flat-square) *很抱歉,本软件已不再维护。* 一个第三方的拷贝漫画带有M3(Material You) 风格,支持动态主题。该App已通过Kotlin来实现,只是标题没改。注意:如果您感觉加载慢的话而且没有使用VPN的话,建议打开设置中的“使用境外CDN”,说不定可以加载得出来。 希望大家可以多使用官方App,我写这个App只是用来练习。这个里面的代码都很简单,即使以后也可以给初学者一定的帮助。但我并不推荐您来查看我的代码。如果您想要学习的话,Kotatsu 和 Tachiyomi的代码完全够用。多看几遍就可以弄懂。 下载功能只能下载,在联网加载的时候会加载本地下载的漫画,但是我还没完成离线观看的逻辑。下载目录在 _Android/com.shicheeng.copymanga/Downloads_ 下面,您可以自己手动复制到其他地方下观看。 本项目部分迁移到Compose。 **注意:请务必卸载以前的那个早期版本** ## 灵感来源 * [fumiama/copymanga](https://github.com/fumiama/copymanga) * [misaka10843/copymanga-downloader](https://github.com/misaka10843/copymanga-downloader) * [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) * [kotatsu](https://github.com/KotatsuApp/Kotatsu) ## 截屏
1
## 关于Api api 来源于官方app API ## 后续功能 * ~~下载~~(现在可离线查看下载的漫画) * ~~记录位置~~(将历史记录保存在本地,_登录后可已上传到网页。_) * ~~登录~~(可以登录了,但是**多用户**没有测试过。) * ~~搜索~~(已完成) ## License MIT License ### 中文解释如下(来自[维基百科](https://zh.wikipedia.org/wiki/MIT%E8%A8%B1%E5%8F%AF%E8%AD%89)) #### 被许可人权利 特此授予任何人免费获得本软件和相关文档文件(“软件”)副本的许可,不受限制地处理本软件,包括但不限于使用、复制、修改、合并 、发布、分发、再许可的权利, 被许可人有权利使用、复制、修改、合并、出版发行、散布、再许可和/或贩售软件及软件的副本,及授予被供应人同等权利,惟服从以下义务。 #### 被许可人义务 在软件和软件的所有副本中都必须包含以上著作权声明和本许可声明。 ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' id 'org.jetbrains.kotlin.plugin.parcelize' id 'androidx.navigation.safeargs' id 'com.mikepenz.aboutlibraries.plugin' id 'kotlin-android' id 'com.google.dagger.hilt.android' id 'com.google.devtools.ksp' } android { compileSdk 34 defaultConfig { applicationId "com.shicheeng.copymanga" minSdk 26 targetSdk 34 versionCode 3 versionName "1.0.5-FIX-1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { compose true buildConfig true } kotlinOptions { jvmTarget = '17' } androidResources { generateLocaleConfig true } composeOptions { kotlinCompilerExtensionVersion '1.5.8' } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } viewBinding { enabled = true } namespace 'com.shicheeng.copymanga' } dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.code.gson:gson:2.10.1' // define a BOM and its version implementation(platform("com.squareup.okhttp3:okhttp-bom:4.9.3")) //SSIV implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f' //Okhttp // define any required OkHttp artifacts without version implementation("com.squareup.okhttp3:okhttp") implementation("com.squareup.okhttp3:logging-interceptor") implementation 'com.squareup.okio:okio:3.5.0' //Glide implementation 'com.github.bumptech.glide:glide:4.15.0' //Core implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.lifecycle:lifecycle-process:2.7.0' ksp 'com.github.bumptech.glide:compiler:4.13.2' def lifecycle_version = "2.6.1" // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // ViewModel utilities for Compose implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version" // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" //Service implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version" implementation "androidx.fragment:fragment-ktx:1.6.2" implementation "androidx.activity:activity-ktx:1.8.2" implementation 'androidx.palette:palette-ktx:1.0.0' //Cache implementation 'com.github.solkin:disk-lru-cache:1.4' //Navigation def nav_version = "2.7.6" // Kotlin implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" //Paging3 def paging_version = "3.1.1" implementation "androidx.paging:paging-runtime-ktx:$paging_version" // Testing Fragments in Isolation debugImplementation "androidx.fragment:fragment-testing:1.6.2" //Room implementation "androidx.room:room-ktx:2.6.1" ksp "androidx.room:room-compiler:2.6.1" androidTestImplementation "androidx.room:room-testing:2.6.1" //AboutLibrary def latestAboutLibsRelease = "10.5.2" implementation "com.mikepenz:aboutlibraries-core:$latestAboutLibsRelease" implementation "com.mikepenz:aboutlibraries:${latestAboutLibsRelease}" implementation "androidx.work:work-runtime-ktx:2.9.0" implementation 'androidx.hilt:hilt-work:1.1.0' // When using Kotlin. kapt 'androidx.hilt:hilt-compiler:1.1.0' //Android Preference implementation 'androidx.preference:preference-ktx:1.2.1' //Compose // Import the Compose BOM implementation platform('androidx.compose:compose-bom:2024.01.00') // Override Material Design 3 library version with a pre-release version implementation 'androidx.compose.material3:material3:1.2.0-rc01' // Import other Compose libraries without version numbers implementation 'androidx.compose.foundation:foundation' implementation "com.google.accompanist:accompanist-themeadapter-material3:0.33.1-alpha" implementation 'androidx.compose.ui:ui-tooling' implementation 'androidx.activity:activity-compose:1.8.2' implementation "com.google.accompanist:accompanist-pager-indicators:0.31.3-beta" implementation "androidx.navigation:navigation-compose:2.7.6" //Optional - Jetpack Compose integration implementation "androidx.paging:paging-compose:3.2.1" //Anime implementation "androidx.compose.animation:animation-graphics" implementation "io.github.fornewid:material-motion-compose-navigation:1.1.0" //Coil implementation "io.coil-kt:coil:2.4.0" implementation "io.coil-kt:coil-compose:2.4.0" //Retrofit implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' //Moshi implementation "com.squareup.moshi:moshi-kotlin:1.15.0" ksp "com.squareup.moshi:moshi-kotlin-codegen:1.14.0" //Hilt implementation "com.google.dagger:hilt-android:2.48.1" kapt "com.google.dagger:hilt-compiler:2.48.1" implementation 'androidx.hilt:hilt-navigation-compose:1.1.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } kapt { correctErrorTypes true } ================================================ FILE: app/debug/output-metadata.json ================================================ { "version": 3, "artifactType": { "type": "APK", "kind": "Directory" }, "applicationId": "com.shicheeng.copymanga", "variantName": "debug", "elements": [ { "type": "SINGLE", "filters": [], "attributes": [], "versionCode": 1, "versionName": "1.0.2", "outputFile": "app-debug.apk" } ], "elementType": "File" } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -keep class com.shicheeng.copymanga.data.MangaInfoChapterDataBean -keep class com.shicheeng.copymanga.data.LastMangaDownload -keep class com.shicheeng.copymanga.data.MangaDownloadChapterInfoModel -keep class com.shicheeng.copymanga.data.MangaDownloads -keep class com.shicheeng.copymanga.data.PersonalInnerDataModel -keep class com.shicheeng.copymanga.data.MangaSortBean # Please add these rules to your existing keep rules in order to suppress warnings. # This is generated automatically by the Android Gradle plugin. -dontwarn org.bouncycastle.jsse.BCSSLParameters -dontwarn org.bouncycastle.jsse.BCSSLSocket -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider -dontwarn org.conscrypt.Conscrypt$Version -dontwarn org.conscrypt.Conscrypt -dontwarn org.conscrypt.ConscryptHostnameVerifier -dontwarn org.openjsse.javax.net.ssl.SSLParameters -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE # Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and # EnclosingMethod is required to use InnerClasses. -keepattributes Signature, InnerClasses, EnclosingMethod # Retrofit does reflection on method and parameter annotations. -keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations # Keep annotation default values (e.g., retrofit2.http.Field.encoded). -keepattributes AnnotationDefault # Retain service method parameters when optimizing. -keepclassmembers,allowshrinking,allowobfuscation interface * { @retrofit2.http.* ; } # Ignore annotation used for build tooling. -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement # Ignore JSR 305 annotations for embedding nullability information. -dontwarn javax.annotation.** # Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. -dontwarn kotlin.Unit # Top-level functions that can only be used by Kotlin. -dontwarn retrofit2.KotlinExtensions -dontwarn retrofit2.KotlinExtensions$* # With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy # and replaces all potential values with null. Explicitly keeping the interfaces prevents this. -if interface * { @retrofit2.http.* ; } -keep,allowobfuscation interface <1> # Keep inherited services. -if interface * { @retrofit2.http.* ; } -keep,allowobfuscation interface * extends <1> # With R8 full mode generic signatures are stripped for classes that are not # kept. Suspend functions are wrapped in continuations where the type argument # is used. -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation # R8 full mode strips generic signatures from return types if not kept. -if interface * { @retrofit2.http.* public *** *(...); } -keep,allowoptimization,allowshrinking,allowobfuscation class <3> ================================================ FILE: app/release/output-metadata.json ================================================ { "version": 3, "artifactType": { "type": "APK", "kind": "Directory" }, "applicationId": "com.shicheeng.copymanga", "variantName": "release", "elements": [ { "type": "SINGLE", "filters": [], "attributes": [], "versionCode": 3, "versionName": "1.0.5-FIX-1", "outputFile": "app-release.apk" } ], "elementType": "File" } ================================================ FILE: app/src/androidTest/java/com/shicheeng/copymanga/ExampleInstrumentedTest.java ================================================ package com.shicheeng.copymanga; import android.content.Context; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.*; /** * Instrumented test, which will execute on an Android device. * * @see Testing documentation */ @RunWith(AndroidJUnit4.class) public class ExampleInstrumentedTest { @Test public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); assertEquals("com.shicheeng.copymanga", appContext.getPackageName()); } } ================================================ FILE: app/src/debug/res/drawable-anydpi/ic_explore_outline.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-anydpi/ic_setting_outline.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-anydpi/ic_swith_horiz.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-anydpi/ic_swith_vert.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-anydpi/ic_trend_up.xml ================================================ ================================================ FILE: app/src/debug/res/mipmap-anydpi-v26/ic_copy.xml ================================================ ================================================ FILE: app/src/debug/res/values/ic_copy_background.xml ================================================ #2A85C6 ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/CrashHandler.kt ================================================ package com.shicheeng.copymanga import android.content.Context import com.shicheeng.copymanga.error.ErrorActivity import java.lang.Thread.UncaughtExceptionHandler @Deprecated("先暂时弃用,这个方法还没学会") class CrashHandler( private val context: Context, ) : UncaughtExceptionHandler { init { Thread.setDefaultUncaughtExceptionHandler(this) } override fun uncaughtException( thread: Thread, error: Throwable, ) { ErrorActivity.newIntentInstance(context = context, error.message).let { context.startActivity(it) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/MainActivity.kt ================================================ package com.shicheeng.copymanga import android.Manifest import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Html import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.view.WindowCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.shicheeng.copymanga.app.AppAttachCompatActivity import com.shicheeng.copymanga.data.VersionUnit import com.shicheeng.copymanga.ui.screen.MainComposeNavigation import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.ui.theme.CopyMangaTheme import com.shicheeng.copymanga.util.FileCacheUtils import com.shicheeng.copymanga.util.collectRepeatLifecycle import com.shicheeng.copymanga.viewmodel.RootViewModel import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppAttachCompatActivity() { @Inject lateinit var settingPref: SettingPref private val mainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) requestNotificationsPermission() setContent { CopyMangaTheme { CompositionLocalProvider( LocalSettingPreference provides settingPref, ) { MainComposeNavigation() } } } if (!settingPref.pauseUpdateDetector.value) { mainViewModel.updateData.collectRepeatLifecycle(this) { onUpdateAttach(it) } } } private fun requestNotificationsPermission() { if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( /*context=*/this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1 ) } } /** * 更新弹窗。由于一般的更新内容为简体中文,故不做i18n处理。 * @param versionUnit 版本更新单位,为空则表示没有更新。 * * @author ShihCheeng and refer to Kotatsu. */ private fun onUpdateAttach(versionUnit: VersionUnit?) { if (versionUnit == null) { return } val message = buildSpannedString { append("版本:") append(versionUnit.versionName) appendLine("大小:" + FileCacheUtils.getFormatSize(versionUnit.apkSize.toDouble())) appendLine("类型:" + versionUnit.versionId.type) appendLine() appendLine(versionUnit.description) } val dialog = MaterialAlertDialogBuilder( this, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered ).apply { setTitle(R.string.new_version) setMessage(Html.fromHtml(message.toString(), Html.FROM_HTML_MODE_COMPACT)) setIcon(R.drawable.baseline_security_update_24) } dialog.setPositiveButton(R.string.update) { dialogInterface: DialogInterface, _: Int -> val intent = Intent(Intent.ACTION_VIEW, Uri.parse(versionUnit.apkUrl)) startActivity(intent) dialogInterface.dismiss() } dialog.setNegativeButton(android.R.string.cancel) { dialogInterface: DialogInterface, _: Int -> dialogInterface.dismiss() } dialog.setNeutralButton(R.string.website_look) { dialogInterface: DialogInterface, _: Int -> val intent = Intent(Intent.ACTION_VIEW, Uri.parse(versionUnit.htmlUrl)) startActivity(intent) dialogInterface.dismiss() } dialog.show() } } val LocalSettingPreference = staticCompositionLocalOf { error("NO LOCAL SETTING PROVIDE") } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/MangaReaderActivity.kt ================================================ package com.shicheeng.copymanga import android.annotation.TargetApi import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.drawable.RippleDrawable import android.os.Build import android.os.Bundle import android.transition.Slide import android.transition.TransitionManager import android.transition.TransitionSet import android.view.Gravity import android.view.KeyEvent import android.view.MotionEvent import android.view.ViewGroup import android.view.WindowManager import androidx.activity.viewModels import androidx.core.graphics.ColorUtils import androidx.core.graphics.Insets import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.ViewPager2.LAYOUT_DIRECTION_LTR import androidx.viewpager2.widget.ViewPager2.LAYOUT_DIRECTION_RTL import com.google.android.material.shape.MaterialShapeDrawable import com.shicheeng.copymanga.app.AppAttachCompatActivity import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.ReaderContent import com.shicheeng.copymanga.data.ReaderState import com.shicheeng.copymanga.databinding.ActivityMangaReaderBinding import com.shicheeng.copymanga.dialog.ConfigPagerSheet import com.shicheeng.copymanga.fm.delegate.IdlingDelegate import com.shicheeng.copymanga.fm.reader.MangaLoader import com.shicheeng.copymanga.fm.reader.ReaderManager import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.fm.reader.noraml.PageSliderFormatter import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.GestureHelper import com.shicheeng.copymanga.util.PageSelectPosition import com.shicheeng.copymanga.util.ReaderSliderAttach import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.util.getThemeColor import com.shicheeng.copymanga.util.hasGlobalPoint import com.shicheeng.copymanga.util.observe import com.shicheeng.copymanga.util.transformPair import com.shicheeng.copymanga.view.control.ReaderControl import com.shicheeng.copymanga.viewmodel.ReaderViewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit import javax.inject.Inject @AndroidEntryPoint class MangaReaderActivity : AppAttachCompatActivity(), ConfigPagerSheet.CallBack, PageSelectPosition, GestureHelper.GestureListener, ReaderControl.ControlDelegateListener, IdlingDelegate.IdleCallback { private lateinit var binding: ActivityMangaReaderBinding private val viewModel by viewModels() private val windowInsetsController by lazy { WindowInsetsControllerCompat(window, binding.root) } @Inject lateinit var settingPref: SettingPref private lateinit var readerManager: ReaderManager private lateinit var gestureHelper: GestureHelper private lateinit var control: ReaderControl private var isLast: Boolean = false private var gestureInsets: Insets = Insets.NONE private val idlingDelegate = IdlingDelegate(this) override val readerMode: ReaderMode? get() = readerManager.currentReaderMode override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMangaReaderBinding.inflate(layoutInflater) setContentView(binding.root) WindowCompat.setDecorFitsSystemWindows(window, false) setCutoutShort(settingPref.cutoutDisplay) windowsInsets(binding.root) { view, systemGesture -> gestureInsets = systemGesture binding.mangaReaderToolbar.updateLayoutParams { topMargin = top } binding.mangaReaderBottomToolbar.updateLayoutParams { bottomMargin = bottom } view.updateLayoutParams { leftMargin = left rightMargin = right } } readerManager = ReaderManager(supportFragmentManager, R.id.manga_reader_container) gestureHelper = GestureHelper(this, this) control = ReaderControl(this, settingPref = settingPref) viewModel.readerModel.observe(this, Lifecycle.State.STARTED) { initializeReaderMode(it) } viewModel.information.transformPair().observe(this, this::onUIChange) viewModel.errorHandler.observe(this, this::onError) viewModel.loadingCounter.observe(this, this::onLoading) viewModel.mangaContent.observe(this, this::withPageContent) initializeBottomMenu() binding.mangaReaderSlider.setLabelFormatter(PageSliderFormatter()) ReaderSliderAttach(this, viewModel).attach(binding.mangaReaderSlider) binding.mangaReaderNext.setOnClickListener { loadChapter(true) } binding.mangaReaderPrevious.setOnClickListener { loadChapter(false) } idlingDelegate.bindToLifecycle(this) } private fun initializeReaderMode(readerMode: ReaderMode?) { if (readerMode == null) return binding.readerMangaModeTip.text = when (readerMode) { ReaderMode.NORMAL -> getString(R.string.japanese_r_to_l) ReaderMode.STANDARD -> getString(R.string.manga_mode_l_t_r) ReaderMode.WEBTOON -> getString(R.string.korea_chinese_top_to_bottom) } if (readerManager.currentReaderMode != readerMode) { readerManager.replace(readerMode) } } private fun withPageContent(readerContent: ReaderContent) { if (readerContent.list.isNotEmpty()) { hideSystemBar(true) } } private fun initializeBottomMenu() { setSupportActionBar(binding.mangaReaderToolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) binding.mangaReaderToolbar.setNavigationOnClickListener { finish() } //The bottom menu refer from Tachiyomi val materialShape = (binding.mangaReaderToolbar.background as MaterialShapeDrawable) .apply { elevation = resources.getDimension(com.google.android.material.R.dimen.m3_sys_elevation_level2) alpha = 242 } binding.mangaReaderBottomToolbar.background = materialShape.copy(this@MangaReaderActivity) binding.mangaReaderSeeker.background = materialShape.copy(this@MangaReaderActivity)?.apply { setCornerSize(999f) } listOf( binding.mangaReaderPrevious, binding.mangaReaderNext, binding.mangaReaderSetting ).forEach { it.background = binding.mangaReaderSeeker.background.copy(this) it.foreground = RippleDrawable( ColorStateList.valueOf(getThemeColor(android.R.attr.colorControlHighlight)), null, it.background, ) } binding.mangaReaderSetting.setOnClickListener { ConfigPagerSheet.show( fragmentManager = supportFragmentManager, reader = readerManager.currentReaderMode ?: return@setOnClickListener ) } val toolbarColor = ColorUtils.setAlphaComponent( materialShape.resolvedTintColor, materialShape.alpha ) window.statusBarColor = toolbarColor window.navigationBarColor = toolbarColor } // FIXME: 有时候没有提示 private fun onUIChange(pair: Pair) { val (old: ReaderState?, state: ReaderState?) = pair title = state?.mangaName ?: old?.mangaName ?: getString(android.R.string.unknownName) if (state == null) { supportActionBar?.subtitle = null binding.mangaReaderSeeker.isVisible = false return } binding.readerMangaSubtitle.text = state.subTime ?: getString(R.string.local) supportActionBar?.subtitle = state.chapterName ?: getString(android.R.string.unknownName) isLast = state.currentPage == state.totalPage - 1 if (old?.chapterName != null && state.chapterName != old.chapterName) { if (!state.chapterName.isNullOrEmpty()) { binding.mangaReaderCircularProgressIndicator.tip( state.chapterName, TimeUnit.SECONDS.toMillis(1) ) } } binding.mangaReaderPageIndicator.text = getString(R.string.chapter_page_indicator, (state.currentPage + 1), state.totalPage) binding.mangaReaderChapterTotalNumber.text = state.totalPage.toString() binding.mangaReaderChapterNowNumber.text = (state.currentPage.plus(1)).toString() if (!state.isSliderAvailable()) { binding.mangaReaderSeeker.isInvisible = true } else { binding.mangaReaderSeeker.isInvisible = false binding.mangaReaderSlider.valueTo = (state.totalPage.toFloat() - 1) binding.mangaReaderSlider.value = state.currentPage.toFloat() } viewModel.saveLocalChapterState(state.currentPage) } override fun onPositionCallBack(page: MangaReaderPage) { lifecycleScope.launch(Dispatchers.Default) { val pages = viewModel.mangaContent.value.list val index = pages.indexOfFirst { it.urlHashCode == page.urlHashCode } if (index != -1) { withContext(Dispatchers.Main) { readerManager.currentReader?.moveToPosition(position = index, index <= 2) } } } } override fun onTouch(area: Int) { control.onGridTouch(area, binding.mangaReaderContainer) } override fun onProcessTouch(rawX: Int, rawY: Int): Boolean { return if ( rawX <= gestureInsets.left || rawY <= gestureInsets.top || rawX >= binding.root.width - gestureInsets.right || rawY >= binding.root.height - gestureInsets.bottom || binding.mangaReaderToolbar.hasGlobalPoint(rawX, rawY) || binding.mangaReaderBottomToolbar.hasGlobalPoint(rawX, rawY) ) { false } else { val touchable = window.peekDecorView()?.touchables touchable?.none { it.hasGlobalPoint(rawX, rawY) } ?: true } } override fun scrollPage(delta: Int) { readerManager.currentReader?.moveDelta(delta) } override fun hide() { hideSystemBar(binding.mangaReaderToolbar.isVisible) } override fun dispatchTouchEvent(ev: MotionEvent): Boolean { gestureHelper.dispatchTouchEvent(ev) return super.dispatchTouchEvent(ev) } private fun loadChapter(isNext: Boolean) { val uuid = viewModel.getCurrentReaderState().uuid viewModel.loadNextPrvChapter(uuid, isNext) } override fun onIdle() { viewModel.saveCurrentState(readerManager.currentReader?.currentState()) } override fun onUserInteraction() { super.onUserInteraction() idlingDelegate.onUserInteraction() } override fun onModeChange(mode: ReaderMode) { rebuildReaderNavigation(mode) viewModel.switchMode(mode) viewModel.saveCurrentState(readerManager.currentReader?.currentState()) } private fun onError(e: Throwable?) { with(binding.layoutErrorInclude) { errorTextTip.setTextColor(getThemeColor(com.google.android.material.R.attr.colorSurface)) errorTextTipDesc.apply { setTextColor(getThemeColor(com.google.android.material.R.attr.colorSurface)) text = e?.message } btnErrorRetry.setOnClickListener { viewModel.retry() this.root.isVisible = false } } } private fun onLoading(boolean: Boolean) { val hasPages = viewModel.mangaContent.value.list.isNotEmpty() binding.loadIndicator.isVisible = boolean && !hasPages if (boolean && hasPages) { binding.mangaReaderCircularProgressIndicator.show(R.string.in_loading_next_chapter) } else { binding.mangaReaderCircularProgressIndicator.hide() } } private fun rebuildReaderNavigation(mode: ReaderMode) { if (mode == ReaderMode.STANDARD || mode == ReaderMode.WEBTOON) { binding.mangaReaderNav.layoutDirection = LAYOUT_DIRECTION_LTR } else { binding.mangaReaderNav.layoutDirection = LAYOUT_DIRECTION_RTL } } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { return when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> { readerManager.currentReader?.moveDelta(-1) true } KeyEvent.KEYCODE_VOLUME_DOWN -> { readerManager.currentReader?.moveDelta(1) true } else -> super.onKeyDown(keyCode, event) } } private fun hideSystemBar( isHide: Boolean, ) { val transition = TransitionSet() .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTransition(Slide(Gravity.TOP).addTarget(binding.mangaReaderToolbar)) .addTransition(Slide(Gravity.BOTTOM).addTarget(binding.mangaReaderBottomSheet)) TransitionManager.beginDelayedTransition(binding.root, transition) binding.mangaReaderBottomSheet.isGone = isHide binding.mangaReaderToolbar.isGone = isHide binding.mangaReaderPageIndicator.isVisible = isHide if (isHide) { windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } else { windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) } } @TargetApi(Build.VERSION_CODES.P) private fun setCutoutShort(enabled: Boolean) { window.attributes.layoutInDisplayCutoutMode = when (enabled) { true -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES false -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER } // Trigger relayout hideSystemBar(!binding.mangaReaderToolbar.isVisible) } companion object { /** * 跳转到[MangaReaderActivity] * @param pathWord Path word * @param uuid 章节uuid */ fun newInstance( context: Context, pathWord: String, uuid: String, ): Intent { val intent = Intent(context, MangaReaderActivity::class.java) intent.putExtra(MangaLoader.MANGA_PATH_WORD, pathWord) intent.putExtra(MangaLoader.MANGA_UUID, uuid) return intent } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/MyApp.kt ================================================ package com.shicheeng.copymanga import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.google.android.material.color.DynamicColors import com.shicheeng.copymanga.server.work.DetectMangaUpdateWork import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.ThemeMode import com.shicheeng.copymanga.util.setSystemNightMode import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @HiltAndroidApp class MyApp : Application(), Configuration.Provider { companion object { lateinit var appContext: Context } @Inject lateinit var workerFactory: HiltWorkerFactory private lateinit var notificationManager: NotificationManager @Inject lateinit var settingPref: SettingPref override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() override fun onCreate() { super.onCreate() notificationManager = applicationContext .getSystemService(NotificationManager::class.java) DynamicColors.applyToActivitiesIfAvailable(this) appContext = applicationContext bindNotification() val themeMode = ThemeMode.valueOf(settingPref.appThemeMode) setSystemNightMode(themeMode) } private fun bindNotification() { val name = getString(R.string.update_manga) val importance = NotificationManager.IMPORTANCE_DEFAULT val mChannel = NotificationChannel( /* id = */ DetectMangaUpdateWork.DETECT_UPDATE_CHANELLE, /* name = */ name, /* importance = */ importance ) notificationManager.createNotificationChannel(mChannel) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/app/AppAttachCompatActivity.kt ================================================ package com.shicheeng.copymanga.app import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat open class AppAttachCompatActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) } inline fun windowsInsets( root: View, crossinline update: Insets.(v: View, gestureInsets: Insets) -> Unit, ) { ViewCompat.setOnApplyWindowInsetsListener(root) { view: View, windowInsetsCompat: WindowInsetsCompat -> val insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()) val systemGestureInsets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemGestures()) update(insets, view, systemGestureInsets) WindowInsetsCompat.Builder() .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) .build() } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/app/BaseFragment.kt ================================================ package com.shicheeng.copymanga.app import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding abstract class BaseFragment : Fragment(), View.OnAttachStateChangeListener { private var _binding: VB? = null protected val binding: VB get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { _binding = onViewBindingIn(inflater, container) binding.root.addOnAttachStateChangeListener(this) return binding.root } override fun onViewAttachedToWindow(v: View) { val insetsCompat = ViewCompat.getRootWindowInsets(v) val systemBarInsets = insetsCompat?.getInsets(WindowInsetsCompat.Type.systemBars()) onFragmentInsets(systemBarInsets, v) } override fun onViewDetachedFromWindow(v: View) { } abstract fun onFragmentInsets(systemBarInsets: Insets?, view: View) abstract fun onViewBindingIn(inflater: LayoutInflater, container: ViewGroup?): VB override fun onDestroy() { _binding = null super.onDestroy() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/dao/MangaLoginDao.kt ================================================ package com.shicheeng.copymanga.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert import com.shicheeng.copymanga.data.login.LocalLoginDataModel import kotlinx.coroutines.flow.Flow @Dao interface MangaLoginDao { @Upsert suspend fun updateOrInsertLoginData(localLoginDataModel: LocalLoginDataModel) @Query("SELECT * FROM LocalLoginDataModel") fun getLoginData(): Flow> @Query("SELECT * FROM LocalLoginDataModel") suspend fun getLoginDataAsync(): List @Query("SELECT * FROM LocalLoginDataModel where userID = :userID LIMIT 1") fun getLoginDataByUserId(userID: String): Flow @Query("SELECT * FROM LocalLoginDataModel where userID = :userID LIMIT 1") suspend fun getLoginDataByUserIdSafety(userID: String?): LocalLoginDataModel? @Upsert suspend fun updateOrInsertLoginData(vararg localLoginDataModels: LocalLoginDataModel) @Query("SELECT token FROM LocalLoginDataModel where userID = :uuid LIMIT 1") fun getCurrentToken(uuid: String): String @Query("SELECT isExpired FROM LocalLoginDataModel where userID = :uuid LIMIT 1") fun isExpired(uuid: String): Boolean @Query("SELECT isExpired FROM LocalLoginDataModel where userID = :uuid LIMIT 1") fun isExpiredFlow(uuid: String): Flow @Delete suspend fun deleteLoginData(localLoginDataModel: LocalLoginDataModel) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/dao/MangeLocalHistoryDao.kt ================================================ package com.shicheeng.copymanga.dao import androidx.room.* import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import kotlinx.coroutines.flow.Flow @Dao interface MangeLocalHistoryDao { @Query("SELECT * FROM manga_history_key ORDER by time DESC") fun getAllHistory(): Flow> @Query("SELECT * FROM manga_history_key ORDER by time DESC") suspend fun fetchTotalManga(): List @Query("SELECT * FROM manga_history_key WHERE pathWord LIKE :pathWord LIMIT 1") suspend fun getHistoryForInfoByPathWord(pathWord: String): MangaHistoryDataModel? @Query("SELECT * FROM manga_history_key WHERE pathWord LIKE :pathWord LIMIT 1") fun fetchHistoryByPathWordInFlow(pathWord: String): Flow @Transaction @Query("SELECT * FROM manga_history_key WHERE pathWord = :pathWord LIMIT 1") suspend fun getMangaByPathWord(pathWord: String): LocalSavableMangaModel? @Query("SELECT * FROM LocalChapter WHERE comicPathWord = :pathWord") suspend fun fetchMangaChaptersByPathWord(pathWord: String): List? @Query("SELECT * FROM LocalChapter WHERE comicPathWord = :pathWord") fun fetchMangaChaptersByPathWordFlow(pathWord: String): Flow?> @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun addLocal(manga: MangaHistoryDataModel) @Upsert suspend fun addLocalChapter(chapter: LocalChapter) @Upsert suspend fun addLocalChapter(vararg chapter: LocalChapter) @Upsert suspend fun updateLocal(manga: MangaHistoryDataModel) @Query("DELETE FROM manga_history_key") suspend fun deleteAllHistory() @Delete suspend fun deleteSingle(manga: MangaHistoryDataModel) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/dao/SearchHistoryDao.kt ================================================ package com.shicheeng.copymanga.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert import com.shicheeng.copymanga.data.searchhistory.SearchHistory import kotlinx.coroutines.flow.Flow @Dao interface SearchHistoryDao { @Query("SELECT * FROM SearchHistory ORDER by time DESC") fun loadWordHistory(): Flow> @Delete suspend fun detectSearchedWordHistory(searchHistory: SearchHistory) @Query("DELETE FROM SearchHistory") suspend fun delThing() @Upsert suspend fun upsertWord(searchHistory: SearchHistory) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/BannerList.java ================================================ package com.shicheeng.copymanga.data; import com.google.gson.JsonObject; public class BannerList { private JsonObject jsonObject; public void setJsonObject(JsonObject jsonObject) { this.jsonObject = jsonObject; } public JsonObject getJsonObject() { return jsonObject; } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/ChipTextBean.kt ================================================ package com.shicheeng.copymanga.data import androidx.annotation.DrawableRes data class ChipTextBean( var text: String, var pathWord: String, @DrawableRes val ids: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/DataBannerBean.java ================================================ package com.shicheeng.copymanga.data; public class DataBannerBean { private String bannerImageUrl; private String bannerBrief; private String uuidManga; public DataBannerBean() { } public String getBannerBrief() { return bannerBrief; } public void setBannerBrief(String bannerBrief) { this.bannerBrief = bannerBrief; } public String getBannerImageUrl() { return bannerImageUrl; } public void setBannerImageUrl(String bannerImageUrl) { this.bannerImageUrl = bannerImageUrl; } public String getUuidManga() { return uuidManga; } public void setUuidManga(String uuidManga) { this.uuidManga = uuidManga; } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/ListBeanManga.kt ================================================ package com.shicheeng.copymanga.data data class ListBeanManga( var nameManga: String, var authorManga: String, var urlCoverManga: String, var pathWordManga: String, ) { constructor() : this( nameManga = "", authorManga = "", urlCoverManga = "", pathWordManga = "" ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/LocalManga.kt ================================================ package com.shicheeng.copymanga.data import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import java.io.File data class LocalManga( val localSavableMangaModel: LocalSavableMangaModel, val file:File, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MainPageDataModel.kt ================================================ package com.shicheeng.copymanga.data data class MainPageDataModel( val listBanner: List, val listRecommend: List, val listRankDay: List, val listRankWeek: List, val listRankMonth: List, val listHot: List, val listNewest: List, val listFinished: List, val topicList:List ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MainTopicDataModel.kt ================================================ package com.shicheeng.copymanga.data data class MainTopicDataModel( val name: String, val type: Int, val brief: String, val pathWord: String, val coverUrl: String, val datetimeCreated: String, val period: String, val journal: String, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaGenernal.kt ================================================ package com.shicheeng.copymanga.data data class MangaInfoData( val title: String, val alias: String, val mangaDetail: String, val mangaStatus: String, val authorList: String, val themeList: List, val mangaCoverUrl: String, val mangaUUID: String, val mangaStatusId: Int, val mangaRegion: String, val mangaLastUpdate: String, val mangaPopularNumber: String, ) data class MangaRankMiniModel( val name: String, val author: String, val urlCover: String, val popular: String, val riseHot: String, val pathWord: String, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaHistoryDataModel.kt ================================================ package com.shicheeng.copymanga.data import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.shicheeng.copymanga.data.info.Author import com.shicheeng.copymanga.database.AuthorToStringConvert import com.shicheeng.copymanga.database.StringToBeanConvert @TypeConverters(StringToBeanConvert::class, AuthorToStringConvert::class) @Entity(tableName = "manga_history_key") data class MangaHistoryDataModel( val name: String, val time: Long, val alias: String?, val url: String, @PrimaryKey val pathWord: String, val comicUUID:String, val nameChapter: String, val positionChapter: Int, val positionPage: Int, val readerModeId: Int, val mangaDetail: String, val mangaStatus: String, val authorList: List, val themeList: List, val mangaStatusId: Int, val mangaRegion: String, val mangaLastUpdate: String, val mangaPopularNumber: String, val isSubscribe: Boolean, ) data class MangaState( val uuid: String, val page: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaInfoChapterDataBean.kt ================================================ package com.shicheeng.copymanga.data import android.os.Parcelable import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize data class MangaInfoChapterDataBean( val chapterTitle: String, val chapterTime: String, val uuidText: String, val readerProgress: Int?, val isDownloading: Boolean = false, val pathWord: String, val isSaved: Boolean = false, ) : Parcelable { @IgnoredOnParcel var isSelect: Boolean = false fun toDownloadChapter(): MangaDownloadChapterInfoModel { return MangaDownloadChapterInfoModel(chapterTitle, uuidText, pathWord) } fun toMangaState(): MangaState { return MangaState(uuidText, readerProgress ?: 0) } } @Parcelize data class LastMangaDownload( /** * 漫画名字 */ val mangaName: String, val coverUrl: String, val list: List, ) : Parcelable @Parcelize data class MangaDownloadChapterInfoModel( val chapterTitle: String, /** * 漫画章节的UUID */ val uuidText: String, /** * 漫画的pathWord */ val pathWord: String, ) : Parcelable { override fun hashCode(): Int { return pathWord.hashCode() + uuidText.length + chapterTitle.hashCode() } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as MangaDownloadChapterInfoModel if (chapterTitle != other.chapterTitle) return false if (uuidText != other.uuidText) return false if (pathWord != other.pathWord) return false return true } } @Parcelize data class MangaDownloads( val urlList: List, val wordsList: List, ) : Parcelable { override fun hashCode(): Int { return urlList.size + wordsList.size } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as MangaDownloads if (urlList != other.urlList) return false if (wordsList != other.wordsList) return false return true } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaReadInformation.kt ================================================ package com.shicheeng.copymanga.data data class MangaReadInformation( val subtitle: String?, val time: String?, val size: Int?, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaReaderPage.kt ================================================ package com.shicheeng.copymanga.data data class MangaReaderPage( val url: String, val uuid: String?, val index: Int, val urlHashCode: Int = url.hashCode(), ) { override fun hashCode(): Int { return url.hashCode() + uuid.hashCode() * 212 } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as MangaReaderPage if (url != other.url) return false if (uuid != other.uuid) return false if (index != other.index) return false if (urlHashCode != other.urlHashCode) return false return true } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/MangaSortBean.java ================================================ package com.shicheeng.copymanga.data; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.NonNull; public class MangaSortBean implements Parcelable { private String pathName; private String pathWord; public MangaSortBean(String pathName, String pathWord) { this.pathName = pathName; this.pathWord = pathWord; } public MangaSortBean() { } protected MangaSortBean(Parcel in) { pathName = in.readString(); pathWord = in.readString(); } public static final Creator CREATOR = new Creator() { @Override public MangaSortBean createFromParcel(Parcel in) { return new MangaSortBean(in); } @Override public MangaSortBean[] newArray(int size) { return new MangaSortBean[size]; } }; public String getPathWord() { return pathWord; } public void setPathWord(String pathWord) { this.pathWord = pathWord; } public String getPathName() { return pathName; } public void setPathName(String pathName) { this.pathName = pathName; } @Override public int describeContents() { return 0; } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeString(pathName); dest.writeString(pathWord); } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/PersonalDataModel.kt ================================================ package com.shicheeng.copymanga.data import android.net.Uri import android.os.Parcelable import androidx.annotation.StringRes import kotlinx.parcelize.Parcelize data class PersonalDataModel(@StringRes val title: Int, val list: List) @Parcelize data class PersonalInnerDataModel( val name: String, val url: Uri?, val pathWord: String?, ) : Parcelable ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/ReaderDataModels.kt ================================================ package com.shicheeng.copymanga.data data class ReaderContent(val list: List, val state: MangaState?) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/ReaderState.kt ================================================ package com.shicheeng.copymanga.data data class ReaderState( val mangaName:String?, val chapterName: String?, val subTime: String?, val uuid: String?, val totalPage: Int, val currentPage: Int, val chapterPosition: Int, ){ fun isSliderAvailable(): Boolean { return totalPage > 1 && currentPage < totalPage } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/UpdateMetadata.kt ================================================ package com.shicheeng.copymanga.data data class VersionId( val major: Int, val minor: Int, val build: Int, val type: String, val typeRNum: Int, ) : Comparable { override fun compareTo(other: VersionId): Int { var diff = major.compareTo(other.major) if (diff != 0) return diff diff = minor.compareTo(other.minor) if (diff != 0) return diff diff = build.compareTo(other.build) if (diff != 0) return diff diff = typeCompareWeight(type).compareTo(typeCompareWeight(other.type)) if (diff != 0) return diff return typeRNum.compareTo(other.typeRNum) } private fun typeCompareWeight(type: String): Int = when (type) { "FIX" -> 8 "PATCH" -> 4 "" -> 2 else -> 0 } } data class VersionUnit( val id: Long, val htmlUrl: String, val versionName: String, val apkUrl: String, val apkSize: Long, val description: String, val time: String, val versionId: VersionId = versionId(versionName), ) fun versionId(nameTag: String): VersionId { val part = nameTag.substringBefore("-").split(".") val name = nameTag.substringAfter("-", "") return VersionId( major = part.getOrNull(0)?.toIntOrNull() ?: 0, minor = part.getOrNull(1)?.toIntOrNull() ?: 0, build = part.getOrNull(2)?.toIntOrNull() ?: 0, type = name.filter(Char::isUpperCase), typeRNum = name.filter(Char::isDigit).toIntOrNull() ?: 0 ) } val VersionId.isNormal get() = type.isEmpty() ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/Author.kt ================================================ package com.shicheeng.copymanga.data.authormanga import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/AuthorMangaItem.kt ================================================ package com.shicheeng.copymanga.data.authormanga import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class AuthorMangaItem( @Json(name = "author") val author: List, @Json(name = "cover") val cover: String, @Json(name = "datetime_updated") val datetimeUpdated: String, @Json(name = "females") val females: List, @Json(name = "free_type") val freeType: FreeType, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "theme") val theme: List ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/AuthorsMangaDataModel.kt ================================================ package com.shicheeng.copymanga.data.authormanga import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class AuthorsMangaDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/FreeType.kt ================================================ package com.shicheeng.copymanga.data.authormanga import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class FreeType( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/authormanga/Results.kt ================================================ package com.shicheeng.copymanga.data.authormanga import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/chapter/Chapter.kt ================================================ package com.shicheeng.copymanga.data.chapter import androidx.annotation.Keep import com.shicheeng.copymanga.data.local.LocalChapter import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Chapter( @Json(name = "comic_id") val comicId: String, @Json(name = "comic_path_word") val comicPathWord: String, @Json(name = "count") val count: Int, var datetime_created: String, @Json(name = "group_id") val groupId: Any?, @Json(name = "group_path_word") val groupPathWord: String, @Json(name = "img_type") val imgType: Int, @Json(name = "index") val index: Int, @Json(name = "name") val name: String, @Json(name = "news") val news: String, @Json(name = "next") val next: String?, @Json(name = "ordered") val ordered: Int, @Json(name = "prev") val prev: String?, @Json(name = "size") val size: Int, @Json(name = "type") val type: Int, @Json(name = "uuid") val uuid: String, ) fun Chapter.toLocalChapter( readIndex: Int, isReadInProgress: Boolean, isDownloaded: Boolean, isReadFinish: Boolean, ): LocalChapter { return LocalChapter( comicId = comicId, comicPathWord = comicPathWord, count = count, datetime_created = datetime_created, groupId = groupId as String?, groupPathWord = groupPathWord, imgType = imgType, index = index, readIndex = readIndex, isReadProgress = isReadInProgress, name = name, news = news, next = next, ordered = ordered, prev = prev, size = size, type = type, uuid = uuid, isDownloaded = isDownloaded, isReadFinish = isReadFinish ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/chapter/ChapterDataModel.kt ================================================ package com.shicheeng.copymanga.data.chapter import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class ChapterDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/chapter/Results.kt ================================================ package com.shicheeng.copymanga.data.chapter import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/collect/ComicCollectDataModel.kt ================================================ package com.shicheeng.copymanga.data.collect import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class ComicCollectDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Any? ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/commentpush/CommentPushDataModel.kt ================================================ package com.shicheeng.copymanga.data.commentpush import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class CommentPushDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Any? ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/downloadmodel/DownloadUiDataModel.kt ================================================ package com.shicheeng.copymanga.data.downloadmodel import android.text.format.DateUtils import androidx.work.WorkInfo import androidx.work.Worker import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import java.lang.invoke.MethodHandles.Lookup import java.util.UUID data class DownloadUiDataModel( val localSavableMangaModel: LocalSavableMangaModel, val pathWord: String, val progress: Int, val max: Int, val error: String?, val isIndeterminate: Boolean, val isPause: Boolean, val isStopped: Boolean, val workerState: WorkInfo.State, val timeStamp: Long, val totalChapter: Int, val id: UUID, val eta: Long, ) : Comparable { val percent: Float get() = if (max > 0) progress / max.toFloat() else 0f val hasEta: Boolean get() = workerState == WorkInfo.State.RUNNING && !isPause && eta > 0L override fun compareTo(other: DownloadUiDataModel): Int { return timeStamp.compareTo(other.timeStamp) } val canResume: Boolean get() = workerState == WorkInfo.State.RUNNING && isPause fun getEtaString(): CharSequence? = if (hasEta) { DateUtils.getRelativeTimeSpanString( eta, System.currentTimeMillis(), DateUtils.SECOND_IN_MILLIS, ) } else { null } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Author.kt ================================================ package com.shicheeng.copymanga.data.finished import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/FinishedMangaDataModel.kt ================================================ package com.shicheeng.copymanga.data.finished import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class FinishedMangaDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/FreeType.kt ================================================ package com.shicheeng.copymanga.data.finished import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class FreeType( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Item.kt ================================================ package com.shicheeng.copymanga.data.finished import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Item( @Json(name = "author") val author: List, @Json(name = "cover") val cover: String, @Json(name = "datetime_updated") val datetimeUpdated: String?, @Json(name = "females") val females: List, @Json(name = "free_type") val freeType: FreeType, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "theme") val theme: List, ) { fun authorReformation() = buildString { author.forEachIndexed { index, a -> append(a.name) if (index != author.lastIndex) { append(",") } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Results.kt ================================================ package com.shicheeng.copymanga.data.finished import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/finished/Theme.kt ================================================ package com.shicheeng.copymanga.data.finished import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Theme( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Author.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Comic.kt ================================================ package com.shicheeng.copymanga.data.info import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "alias") val alias: String?, @Json(name = "author") val author: List, @Json(name = "brief") val brief: String, @Json(name = "clubs") val clubs: List, @Json(name = "cover") val cover: String, @Json(name = "datetime_updated") val datetimeUpdated: String?, @Json(name = "females") val females: List, @Json(name = "free_type") val freeType: FreeType, @Json(name = "img_type") val imgType: Int, @Json(name = "last_chapter") val lastChapter: LastChapter, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "parodies") val parodies: List, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "reclass") val reclass: Reclass, @Json(name = "region") val region: Region, @Json(name = "restrict") val restrict: Restrict, @Json(name = "seo_baidu") val seoBaidu: String, @Json(name = "status") val status: Status, @Json(name = "theme") val theme: List, @Json(name = "uuid") val uuid: String, ){ fun authorReformation() = buildString { author.forEachIndexed { index, a -> append(a.name) if (index != author.lastIndex) { append(",") } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Default.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Default( @Json(name = "count") val count: Int, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/FreeType.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class FreeType( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Groups.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Groups( @Json(name = "default") val default: Default? ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/LastChapter.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class LastChapter( @Json(name = "name") val name: String, @Json(name = "uuid") val uuid: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/MangaInfoDataModel.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class MangaInfoDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Reclass.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Reclass( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Region.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Region( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Restrict.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Restrict( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Results.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "comic") val comic: Comic, @Json(name = "groups") val groups: Groups, @Json(name = "is_lock") val isLock: Boolean, @Json(name = "is_login") val isLogin: Boolean, @Json(name = "is_mobile_bind") val isMobileBind: Boolean, @Json(name = "is_vip") val isVip: Boolean, @Json(name = "popular") val popular: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Status.kt ================================================ package com.shicheeng.copymanga.data.info import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Status( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/info/Theme.kt ================================================ package com.shicheeng.copymanga.data.info import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Theme( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/local/Chapter.kt ================================================ package com.shicheeng.copymanga.data.local import androidx.room.Entity import androidx.room.PrimaryKey import com.shicheeng.copymanga.data.MangaState @Entity data class LocalChapter( val comicId: String, val comicPathWord: String, val count: Int, var datetime_created: String, val groupId: String?, val groupPathWord: String, val imgType: Int, val index: Int, val readIndex: Int, val isReadProgress: Boolean, val name: String, val news: String, val next: String?, val ordered: Int, val prev: String?, val size: Int, val type: Int, @PrimaryKey val uuid: String, val isDownloaded: Boolean, val isReadFinish: Boolean, ) fun LocalChapter.toMangaState(): MangaState { return MangaState( uuid = uuid, page = if (isReadFinish) 0 else readIndex ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/local/LocalSavableMangaModel.kt ================================================ package com.shicheeng.copymanga.data.local import androidx.room.Embedded import androidx.room.Relation import com.shicheeng.copymanga.data.MangaHistoryDataModel data class LocalSavableMangaModel( @Embedded val mangaHistoryDataModel: MangaHistoryDataModel, @Relation( parentColumn = "pathWord", entityColumn = "comicPathWord" ) val list: List, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/lofininfo/LoginInfoDataModel.kt ================================================ package com.shicheeng.copymanga.data.lofininfo import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class LoginInfoDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/lofininfo/Results.kt ================================================ package com.shicheeng.copymanga.data.lofininfo import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "ads_vip_end") val adsVipEnd: Any?, @Json(name = "avatar") val avatar: String, @Json(name = "b_sstv") val bSstv: Boolean, @Json(name = "b_verify_email") val bVerifyEmail: Boolean, @Json(name = "cartoon_vip") val cartoonVip: Int, @Json(name = "cartoon_vip_end") val cartoonVipEnd: Any?, @Json(name = "cartoon_vip_start") val cartoonVipStart: Any?, @Json(name = "close_report") val closeReport: Boolean, @Json(name = "comic_vip") val comicVip: Int, @Json(name = "comic_vip_end") val comicVipEnd: Any?, @Json(name = "comic_vip_start") val comicVipStart: Any?, @Json(name = "datetime_created") val datetimeCreated: String, @Json(name = "day_downloads") val dayDownloads: Int, @Json(name = "day_downloads_refresh") val dayDownloadsRefresh: String, @Json(name = "downloads") val downloads: Int, @Json(name = "email") val email: String, @Json(name = "invite_code") val inviteCode: Any?, @Json(name = "invited") val invited: Any?, @Json(name = "is_authenticated") val isAuthenticated: Boolean, @Json(name = "mobile") val mobile: Any?, @Json(name = "mobile_region") val mobileRegion: Any?, @Json(name = "nickname") val nickname: String, @Json(name = "point") val point: Int, @Json(name = "reward_downloads") val rewardDownloads: Int, @Json(name = "scy_answer") val scyAnswer: Boolean, @Json(name = "user_id") val userId: String, @Json(name = "username") val username: String, @Json(name = "vip_downloads") val vipDownloads: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/login/LocalLoginDataModel.kt ================================================ package com.shicheeng.copymanga.data.login import androidx.room.Entity import androidx.room.PrimaryKey import com.shicheeng.copymanga.data.lofininfo.LoginInfoDataModel @Entity data class LocalLoginDataModel( val avatarImageUrl: String, val nikeName: String, val userName: String, val token: String, @PrimaryKey val userID: String, val email: String, val selected: Boolean, val isExpired: Boolean, ) fun LoginDataModel.toLoginDataModel(isSelected: Boolean = false) = LocalLoginDataModel( nikeName = results.nickname, userName = results.username, email = results.email, token = results.token, userID = results.userId, avatarImageUrl = results.avatar, selected = isSelected, isExpired = false ) fun LoginInfoDataModel.toLoginDataModel( localLoginDataModel: LocalLoginDataModel, isSelected: Boolean = false, isExpired: Boolean, ) = LocalLoginDataModel( nikeName = results.nickname, userName = results.username, email = results.email, token = localLoginDataModel.token, userID = results.userId, avatarImageUrl = results.avatar, selected = isSelected, isExpired = isExpired ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/login/LoginDataModel.kt ================================================ package com.shicheeng.copymanga.data.login import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class LoginDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/login/Results.kt ================================================ package com.shicheeng.copymanga.data.login import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "ads_vip_end") val adsVipEnd: Any?, @Json(name = "avatar") val avatar: String, @Json(name = "b_sstv") val bSstv: Boolean, @Json(name = "b_verify_email") val bVerifyEmail: Boolean, @Json(name = "cartoon_vip") val cartoonVip: Int, @Json(name = "cartoon_vip_end") val cartoonVipEnd: Any?, @Json(name = "cartoon_vip_start") val cartoonVipStart: Any?, @Json(name = "close_report") val closeReport: Boolean, @Json(name = "comic_vip") val comicVip: Int, @Json(name = "comic_vip_end") val comicVipEnd: Any?, @Json(name = "comic_vip_start") val comicVipStart: Any?, @Json(name = "datetime_created") val datetimeCreated: String, @Json(name = "downloads") val downloads: Int, @Json(name = "email") val email: String, @Json(name = "invite_code") val inviteCode: Any?, @Json(name = "invited") val invited: Any?, @Json(name = "is_authenticated") val isAuthenticated: Boolean, @Json(name = "mobile") val mobile: Any?, @Json(name = "mobile_region") val mobileRegion: Any?, @Json(name = "nickname") val nickname: String, @Json(name = "point") val point: Int, @Json(name = "reward_downloads") val rewardDownloads: Int, @Json(name = "scy_answer") val scyAnswer: Boolean, @Json(name = "token") val token: String, @Json(name = "user_id") val userId: String, @Json(name = "username") val username: String, @Json(name = "vip_downloads") val vipDownloads: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/Gender.kt ================================================ package com.shicheeng.copymanga.data.logininfoshort import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Gender( @Json(name = "key") val key: Int, @Json(name = "value") val value: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/GenderX.kt ================================================ package com.shicheeng.copymanga.data.logininfoshort import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class GenderX( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/Info.kt ================================================ package com.shicheeng.copymanga.data.logininfoshort import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Info( @Json(name = "avatar") val avatar: String, @Json(name = "avatar_rp") val avatarRp: String, @Json(name = "gender") val gender: GenderX, @Json(name = "invite_code") val inviteCode: Any?, @Json(name = "mobile") val mobile: Any?, @Json(name = "mobile_region") val mobileRegion: Any?, @Json(name = "nickname") val nickname: String, @Json(name = "username") val username: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/LoginInfoShortDataModel.kt ================================================ package com.shicheeng.copymanga.data.logininfoshort import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class LoginInfoShortDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/logininfoshort/Results.kt ================================================ package com.shicheeng.copymanga.data.logininfoshort import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "genders") val genders: List, @Json(name = "info") val info: Info ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacomment/MangaCommentDataModel.kt ================================================ package com.shicheeng.copymanga.data.mangacomment import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class MangaCommentDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacomment/MangaCommentListItem.kt ================================================ package com.shicheeng.copymanga.data.mangacomment import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class MangaCommentListItem( @Json(name = "comment") val comment: String, @Json(name = "count") val count: Int, @Json(name = "create_at") val createAt: String, @Json(name = "id") val id: Int, @Json(name = "parent_id") val parentId: Any?, @Json(name = "parent_user_id") val parentUserId: Any?, @Json(name = "parent_user_name") val parentUserName: Any?, @Json(name = "user_avatar") val userAvatar: String, @Json(name = "user_id") val userId: String, @Json(name = "user_name") val userName: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacomment/Results.kt ================================================ package com.shicheeng.copymanga.data.mangacomment import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Chapter.kt ================================================ package com.shicheeng.copymanga.data.mangacontent import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Chapter( @Json(name = "comic_id") val comicId: String, @Json(name = "comic_path_word") val comicPathWord: String, @Json(name = "contents") val contents: List, @Json(name = "count") val count: Int, @Json(name = "datetime_created") val datetimeCreated: String, @Json(name = "group_id") val groupId: Any?, @Json(name = "group_path_word") val groupPathWord: String, @Json(name = "img_type") val imgType: Int, @Json(name = "index") val index: Int, @Json(name = "is_long") val isLong: Boolean, @Json(name = "name") val name: String, @Json(name = "news") val news: String, @Json(name = "next") val next: String?, @Json(name = "ordered") val ordered: Int, @Json(name = "prev") val prev: String?, @Json(name = "size") val size: Int, @Json(name = "type") val type: Int, @Json(name = "uuid") val uuid: String, @Json(name = "words") val words: List ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Comic.kt ================================================ package com.shicheeng.copymanga.data.mangacontent import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "restrict") val restrict: Restrict, @Json(name = "uuid") val uuid: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Content.kt ================================================ package com.shicheeng.copymanga.data.mangacontent import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Content( @Json(name = "url") val url: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/MangaContentDataModel.kt ================================================ package com.shicheeng.copymanga.data.mangacontent import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class MangaContentDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Restrict.kt ================================================ package com.shicheeng.copymanga.data.mangacontent import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Restrict( @Json(name = "display") val display: String, @Json(name = "value") val value: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/mangacontent/Results.kt ================================================ package com.shicheeng.copymanga.data.mangacontent import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "chapter") val chapter: Chapter, @Json(name = "comic") val comic: Comic, @Json(name = "is_lock") val isLock: Boolean, @Json(name = "is_login") val isLogin: Boolean, @Json(name = "is_mobile_bind") val isMobileBind: Boolean, @Json(name = "is_vip") val isVip: Boolean, @Json(name = "show_app") val showApp: Boolean ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/Author.kt ================================================ package com.shicheeng.copymanga.data.newsest import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/Comic.kt ================================================ package com.shicheeng.copymanga.data.newsest import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "author") val author: List, @Json(name = "cover") val cover: String, @Json(name = "datetime_updated") val datetimeUpdated: String, @Json(name = "females") val females: List, @Json(name = "img_type") val imgType: Int, @Json(name = "last_chapter_name") val lastChapterName: String, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "theme") val theme: List, ) { fun authorReformation() = buildString { author.forEachIndexed { index, a -> append(a.name) if (index != author.lastIndex) { append(",") } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/MangaBlock.kt ================================================ package com.shicheeng.copymanga.data.newsest import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class MangaBlock( @Json(name = "comic") val comic: Comic, @Json(name = "datetime_created") val datetimeCreated: String, @Json(name = "name") val name: String, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/NewestListDataModel.kt ================================================ package com.shicheeng.copymanga.data.newsest import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class NewestListDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/newsest/Results.kt ================================================ package com.shicheeng.copymanga.data.newsest import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Author.kt ================================================ package com.shicheeng.copymanga.data.rank import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Comic.kt ================================================ package com.shicheeng.copymanga.data.rank import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "author") val author: List, @Json(name = "cover") val cover: String, @Json(name = "females") val females: List, @Json(name = "img_type") val imgType: Int, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "theme") val theme: List, ) { fun authorThat() = buildString { author.forEachIndexed { index: Int, authorIn: Author -> append(authorIn.name) if (index != author.lastIndex) { append(",") } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Item.kt ================================================ package com.shicheeng.copymanga.data.rank import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Item( @Json(name = "comic") val comic: Comic, @Json(name = "date_type") val dateType: Int, @Json(name = "popular") val popular: Int, @Json(name = "rise_num") val riseNum: Int, @Json(name = "rise_sort") val riseSort: Int, @Json(name = "sort") val sort: Int, @Json(name = "sort_last") val sortLast: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/RankDataModel.kt ================================================ package com.shicheeng.copymanga.data.rank import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class RankDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/rank/Results.kt ================================================ package com.shicheeng.copymanga.data.rank import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/recommend/RecommendDataModel.kt ================================================ package com.shicheeng.copymanga.data.recommend import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class RecommendDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results, ) { @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) { @Keep @JsonClass(generateAdapter = true) data class Item( @Json(name = "comic") val comic: Comic, @Json(name = "type") val type: Int, ) { @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "author") val author: List, @Json(name = "cover") val cover: String, @Json(name = "females") val females: List, @Json(name = "img_type") val imgType: Int, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "theme") val theme: List, ) { fun authorReformation() = buildString { author.forEachIndexed { index, a -> append(a.name) if (index != author.lastIndex) { append(",") } } } @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, ) @Keep @JsonClass(generateAdapter = true) data class Theme( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/search/Author.kt ================================================ package com.shicheeng.copymanga.data.search import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "alias") val alias: String?, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/search/Results.kt ================================================ package com.shicheeng.copymanga.data.search import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/search/SearchDataModel.kt ================================================ package com.shicheeng.copymanga.data.search import com.squareup.moshi.Json import androidx.annotation.Keep import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class SearchDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/search/SearchResultDataModel.kt ================================================ package com.shicheeng.copymanga.data.search import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class SearchResultDataModel( @Json(name = "alias") val alias: String?, @Json(name = "author") val author: List, @Json(name = "cover") val cover: String, @Json(name = "img_type") val imgType: Int, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, ) { fun authorReformation() = buildString { author.forEachIndexed { index, a -> append(a.name) if (index != author.lastIndex) { append(",") } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/searchhelpword/SearchTermWordDataModel.kt ================================================ package com.shicheeng.copymanga.data.searchhelpword import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class SearchTermWordDataModel( @Json(name = "code") val code: Int, @Json(name = "data") val `data`: List, @Json(name = "msg") val msg: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/searchhistory/SearchHistory.kt ================================================ package com.shicheeng.copymanga.data.searchhistory import androidx.room.Entity import androidx.room.PrimaryKey @Entity data class SearchHistory( @PrimaryKey val word: String, val time:Long, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/searchrecommend/Data.kt ================================================ package com.shicheeng.copymanga.data.searchrecommend import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Data( @Json(name = "title") val title: String, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/searchrecommend/SearchRecommendDataModel.kt ================================================ package com.shicheeng.copymanga.data.searchrecommend import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class SearchRecommendDataModel( @Json(name = "code") val code: Int, @Json(name = "data") val `data`: List, @Json(name = "msg") val msg: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/Ordering.kt ================================================ package com.shicheeng.copymanga.data.sorttag import com.squareup.moshi.Json import androidx.annotation.Keep @Keep data class Ordering( @Json(name = "datetime_updated") val datetimeUpdated: String, @Json(name = "popular") val popular: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/Results.kt ================================================ package com.shicheeng.copymanga.data.sorttag import androidx.annotation.Keep import com.squareup.moshi.Json @Keep data class Results( @Json(name = "ordering") val ordering: Ordering, @Json(name = "theme") val theme: List, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/SortTagsDataModel.kt ================================================ package com.shicheeng.copymanga.data.sorttag import com.squareup.moshi.Json import androidx.annotation.Keep @Keep data class SortTagsDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/sorttag/Theme.kt ================================================ package com.shicheeng.copymanga.data.sorttag import android.util.Log import androidx.annotation.Keep import com.shicheeng.copymanga.data.MangaSortBean import com.squareup.moshi.Json @Keep data class Theme( @Json(name = "count") val count: Int, @Json(name = "initials") val initials: Int, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, ) { init { Log.d("TAG", "THEME: $pathWord") } fun toMangaSortBean() = MangaSortBean( /* pathName = */ name, /* pathWord = */ pathWord ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/Results.kt ================================================ package com.shicheeng.copymanga.data.topicalllist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/Series.kt ================================================ package com.shicheeng.copymanga.data.topicalllist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Series( @Json(name = "color") val color: String, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/TopicAllListDataModel.kt ================================================ package com.shicheeng.copymanga.data.topicalllist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class TopicAllListDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicalllist/TopicAllListItem.kt ================================================ package com.shicheeng.copymanga.data.topicalllist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class TopicAllListItem( @Json(name = "brief") val brief: String, @Json(name = "cover") val cover: String, @Json(name = "datetime_created") val datetimeCreated: String, @Json(name = "journal") val journal: String, @Json(name = "path_word") val pathWord: String, @Json(name = "period") val period: String, @Json(name = "series") val series: Series, @Json(name = "title") val title: String, @Json(name = "type") val type: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/Last.kt ================================================ package com.shicheeng.copymanga.data.topicinfo import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Last( @Json(name = "path_word") val pathWord: String, @Json(name = "title") val title: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/Results.kt ================================================ package com.shicheeng.copymanga.data.topicinfo import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "brief") val brief: String, @Json(name = "cover") val cover: String, @Json(name = "datetime_created") val datetimeCreated: String, @Json(name = "intro") val intro: String, @Json(name = "journal") val journal: String, @Json(name = "last") val last: Last, @Json(name = "path") val path: Any?, @Json(name = "path_word") val pathWord: String, @Json(name = "period") val period: String, @Json(name = "series") val series: Series, @Json(name = "title") val title: String, @Json(name = "type") val type: Int, @Json(name = "version") val version: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/Series.kt ================================================ package com.shicheeng.copymanga.data.topicinfo import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Series( @Json(name = "color") val color: String, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topicinfo/TopicInfoDataModelX.kt ================================================ package com.shicheeng.copymanga.data.topicinfo import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class TopicInfoDataModelX( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/Author.kt ================================================ package com.shicheeng.copymanga.data.topiclist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/Results.kt ================================================ package com.shicheeng.copymanga.data.topiclist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/Theme.kt ================================================ package com.shicheeng.copymanga.data.topiclist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Theme( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/TopicItem.kt ================================================ package com.shicheeng.copymanga.data.topiclist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class TopicItem( @Json(name = "author") val author: List, @Json(name = "c_type") val cType: Int, @Json(name = "cover") val cover: String, @Json(name = "females") val females: List, @Json(name = "img_type") val imgType: Int, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "parodies") val parodies: List, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "theme") val theme: List, @Json(name = "type") val type: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/topiclist/TopicListDataModel.kt ================================================ package com.shicheeng.copymanga.data.topiclist import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class TopicListDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Author.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Browse.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Browse( @Json(name = "chapter_name") val chapterName: String, @Json(name = "chapter_uuid") val chapterUuid: String, @Json(name = "comic_uuid") val comicUuid: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Comic.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "author") val author: List, @Json(name = "b_display") val bDisplay: Boolean, @Json(name = "browse") val browse: Browse?, @Json(name = "cover") val cover: String, @Json(name = "datetime_updated") val datetimeUpdated: String, @Json(name = "females") val females: List, @Json(name = "last_chapter_id") val lastChapterId: String, @Json(name = "last_chapter_name") val lastChapterName: String, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "status") val status: Int, @Json(name = "theme") val theme: List, @Json(name = "uuid") val uuid: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/LastBrowse.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class LastBrowse( @Json(name = "last_browse_id") val lastBrowseId: String, @Json(name = "last_browse_name") val lastBrowseName: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/Results.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/WebBookshelf.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class WebBookshelf( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webbookshelf/WebBookshelfItem.kt ================================================ package com.shicheeng.copymanga.data.webbookshelf import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class WebBookshelfItem( @Json(name = "b_folder") val bFolder: Boolean, @Json(name = "comic") val comic: Comic, @Json(name = "folder_id") val folderId: Any?, @Json(name = "last_browse") val lastBrowse: LastBrowse?, @Json(name = "name") val name: Any?, @Json(name = "uuid") val uuid: Int ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webcomichistory/Browse.kt ================================================ package com.shicheeng.copymanga.data.webcomichistory import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Browse( @Json(name = "chapter_id") val chapterId: String, @Json(name = "chapter_name") val chapterName: String, @Json(name = "chapter_uuid") val chapterUuid: String, @Json(name = "comic_id") val comicId: String, @Json(name = "comic_uuid") val comicUuid: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webcomichistory/Results.kt ================================================ package com.shicheeng.copymanga.data.webcomichistory import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "browse") val browse: Browse?, @Json(name = "collect") val collect: Int?, @Json(name = "is_lock") val isLock: Boolean, @Json(name = "is_login") val isLogin: Boolean, @Json(name = "is_mobile_bind") val isMobileBind: Boolean, @Json(name = "is_vip") val isVip: Boolean, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webcomichistory/WebComicHistory.kt ================================================ package com.shicheeng.copymanga.data.webcomichistory import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep import com.shicheeng.copymanga.data.local.LocalChapter @Keep @JsonClass(generateAdapter = true) data class WebComicHistory( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/Author.kt ================================================ package com.shicheeng.copymanga.data.webhistory import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Author( @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/Comic.kt ================================================ package com.shicheeng.copymanga.data.webhistory import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class Comic( @Json(name = "author") val author: List, @Json(name = "b_display") val bDisplay: Boolean, @Json(name = "cover") val cover: String, @Json(name = "datetime_updated") val datetimeUpdated: String, @Json(name = "females") val females: List, @Json(name = "last_chapter_id") val lastChapterId: String, @Json(name = "last_chapter_name") val lastChapterName: String, @Json(name = "males") val males: List, @Json(name = "name") val name: String, @Json(name = "path_word") val pathWord: String, @Json(name = "popular") val popular: Int, @Json(name = "status") val status: Int, @Json(name = "theme") val theme: List, @Json(name = "uuid") val uuid: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/Results.kt ================================================ package com.shicheeng.copymanga.data.webhistory import androidx.annotation.Keep import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) data class Results( @Json(name = "limit") val limit: Int, @Json(name = "list") val list: List, @Json(name = "offset") val offset: Int, @Json(name = "total") val total: Int, ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/WebHistoryDataModel.kt ================================================ package com.shicheeng.copymanga.data.webhistory import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class WebHistoryDataModel( @Json(name = "code") val code: Int, @Json(name = "message") val message: String, @Json(name = "results") val results: Results ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/data/webhistory/WebHistoryItem.kt ================================================ package com.shicheeng.copymanga.data.webhistory import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import androidx.annotation.Keep @Keep @JsonClass(generateAdapter = true) data class WebHistoryItem( @Json(name = "comic") val comic: Comic, @Json(name = "id") val id: Int, @Json(name = "last_chapter_id") val lastChapterId: String, @Json(name = "last_chapter_name") val lastChapterName: String ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/database/MangaHistoryDataBase.kt ================================================ package com.shicheeng.copymanga.database import androidx.room.Database import androidx.room.RoomDatabase import com.shicheeng.copymanga.dao.MangeLocalHistoryDao import com.shicheeng.copymanga.dao.SearchHistoryDao import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.searchhistory.SearchHistory @Database( entities = [MangaHistoryDataModel::class, LocalChapter::class, SearchHistory::class], version = 6, exportSchema = false ) abstract class MangaHistoryDataBase : RoomDatabase() { abstract fun historyDao(): MangeLocalHistoryDao abstract fun keyWordDao(): SearchHistoryDao } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/database/MangaLoginDatabase.kt ================================================ package com.shicheeng.copymanga.database import androidx.room.Database import androidx.room.RoomDatabase import com.shicheeng.copymanga.dao.MangaLoginDao import com.shicheeng.copymanga.data.login.LocalLoginDataModel @Database( entities = [LocalLoginDataModel::class], version = 2, exportSchema = false ) abstract class MangaLoginDatabase : RoomDatabase() { abstract fun loginDao(): MangaLoginDao } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/database/StringToBeanConvert.kt ================================================ package com.shicheeng.copymanga.database import androidx.room.TypeConverter import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.data.info.Author class StringToBeanConvert { companion object { private const val SPLIT_OUT = "," private const val SPLIT_INNER = "-" } @TypeConverter fun stringToListBean(string: String): List { return buildList { string.split(SPLIT_OUT).forEach { val inner = it.split(SPLIT_INNER) if (inner.size == 1) { add(MangaSortBean(inner[0], inner[0])) } else { add(MangaSortBean(inner[0], inner[1])) } } } } @TypeConverter fun listBeanToString(list: List): String { return buildString { list.forEachIndexed { index, sortBean -> append("${sortBean.pathName}$SPLIT_INNER${sortBean.pathWord}") if (index != list.lastIndex) { append(SPLIT_OUT) } } } } } class AuthorToStringConvert { companion object { private const val SPLIT_OUT = "," private const val SPLIT_INNER = "-" } @TypeConverter fun stringToListBean(string: String): List { return buildList { string.split(SPLIT_OUT).forEach { val inner = it.split(SPLIT_INNER) if (inner.size == 1) { add(Author(inner[0], inner[0])) } else { add(Author(inner[0], inner[1])) } } } } @TypeConverter fun listBeanToString(list: List): String { return buildString { list.forEachIndexed { index, sortBean -> append("${sortBean.name}$SPLIT_INNER${sortBean.pathWord}") if (index != list.lastIndex) { append(SPLIT_OUT) } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/dialog/ConfigPagerSheet.kt ================================================ package com.shicheeng.copymanga.dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.FragmentManager import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.button.MaterialButtonToggleGroup import com.shicheeng.copymanga.R import com.shicheeng.copymanga.databinding.SheetMangaModelSwitcherBinding import com.shicheeng.copymanga.fm.reader.ReaderMode class ConfigPagerSheet : BottomSheetDialogFragment(), MaterialButtonToggleGroup.OnButtonCheckedListener { private var _binding: SheetMangaModelSwitcherBinding? = null private val binding get() = _binding!! private lateinit var mode: ReaderMode override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mode = arguments?.getInt(MODE_BUNDLE)?.let { ReaderMode.idOf(it) } ?: ReaderMode.NORMAL } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { _binding = SheetMangaModelSwitcherBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.readerSwitcherToHorizontal.isChecked = mode == ReaderMode.WEBTOON binding.readerSwitcherToVert.isChecked = mode == ReaderMode.NORMAL binding.readerSwitcherToLToR.isChecked = mode == ReaderMode.STANDARD binding.readerSwitchersGroup.addOnButtonCheckedListener(this) super.onViewCreated(view, savedInstanceState) } override fun onButtonChecked( group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean, ) { if (!isChecked) { return } val newMode = when (checkedId) { R.id.reader_switcher_to_vert -> ReaderMode.NORMAL R.id.reader_switcher_to_horizontal -> ReaderMode.WEBTOON R.id.reader_switcher_to_l_to_r -> ReaderMode.STANDARD else -> return } if (newMode == mode) { return } findCallBackSetMode()?.onModeChange(newMode) ?: return mode = newMode } private fun findCallBackSetMode(): CallBack? { return (parentFragment as? CallBack) ?: (activity as? CallBack) } override fun onDestroyView() { _binding = null super.onDestroyView() } interface CallBack { fun onModeChange(mode: ReaderMode) } companion object { private const val TAG = "TAG_CONFIG_PAGER" private const val MODE_BUNDLE = "bundle_reader_mode" fun show(fragmentManager: FragmentManager, reader: ReaderMode) { val args = Bundle() args.putInt(MODE_BUNDLE, reader.id) val fragment = ConfigPagerSheet() fragment.arguments = args return fragment.show(fragmentManager, TAG) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/dialog/ListPreferenceXTheme.kt ================================================ package com.shicheeng.copymanga.dialog import android.content.Context import android.util.AttributeSet import androidx.preference.ListPreference class ListPreferenceXTheme @JvmOverloads constructor( context: Context, attr: AttributeSet? = null, defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle, defStyleRes: Int = 0, ) :ListPreference(context,attr,defStyleAttr, defStyleRes){ } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/domin/CopyMangaApi.kt ================================================ package com.shicheeng.copymanga.domin import androidx.annotation.Keep import com.shicheeng.copymanga.data.authormanga.AuthorsMangaDataModel import com.shicheeng.copymanga.data.chapter.ChapterDataModel import com.shicheeng.copymanga.data.commentpush.CommentPushDataModel import com.shicheeng.copymanga.data.finished.FinishedMangaDataModel import com.shicheeng.copymanga.data.info.MangaInfoDataModel import com.shicheeng.copymanga.data.lofininfo.LoginInfoDataModel import com.shicheeng.copymanga.data.login.LoginDataModel import com.shicheeng.copymanga.data.logininfoshort.LoginInfoShortDataModel import com.shicheeng.copymanga.data.mangacomment.MangaCommentDataModel import com.shicheeng.copymanga.data.mangacontent.MangaContentDataModel import com.shicheeng.copymanga.data.newsest.NewestListDataModel import com.shicheeng.copymanga.data.rank.RankDataModel import com.shicheeng.copymanga.data.recommend.RecommendDataModel import com.shicheeng.copymanga.data.search.SearchDataModel import com.shicheeng.copymanga.data.topicalllist.TopicAllListDataModel import com.shicheeng.copymanga.data.topicinfo.TopicInfoDataModelX import com.shicheeng.copymanga.data.topiclist.TopicListDataModel import com.shicheeng.copymanga.data.webbookshelf.WebBookshelf import com.shicheeng.copymanga.data.webcomichistory.WebComicHistory import com.shicheeng.copymanga.data.webhistory.WebHistoryDataModel import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @Keep interface CopyMangaApi { @GET("/api/v3/ranks") suspend fun getRank( @Query("limit") limit: Int = 21, @Query("offset") offset: Int, @Query("date_type") dateType: String, ): RankDataModel @GET("/api/v3/comic2/{path_word}") suspend fun getMangaInfo( @Path("path_word") pathWord: String, @Query("platform") platform: Int = 3, @Query("format") format: String = "json", ): MangaInfoDataModel @GET("/api/v3/recs") suspend fun getMangaRecommend( @Query("pos") pos: Int = 3200102, @Query("limit") limit: Int = 21, @Query("offset") offset: Int, ): RecommendDataModel @GET("/api/v3/update/newest") suspend fun getMangaNewest( @Query("limit") limit: Int = 21, @Query("offset") offset: Int, ): NewestListDataModel @GET("/api/v3/comics") suspend fun fetchMangaFilter( @Query("free_type") freeType: Int = 1, @Query("limit") limit: Int = 21, @Query("offset") offset: Int, @Query("top") top: String? = null, @Query("theme") theme: String? = null, @Query("ordering", encoded = true) ordering: String? = null, @Query("_update") update: Boolean = true, ): FinishedMangaDataModel @GET("/api/v3/comic/{path_word}/group/default/chapters") suspend fun fetchChapters( @Path("path_word") pathWord: String, @Query("limit") limit: Int = 500, @Query("offset") offset: Int = 0, @Query("platform") platform: Int = 1, ): ChapterDataModel @GET("/api/v3/search/comic") suspend fun search( @Query("format") format: String = "json", @Query("limit") limit: Int = 21, @Query("offset") offset: Int, @Query("platform") platform: Int = 1, @Query("q") q: String, ): SearchDataModel @GET("/api/v3/comic/{path_word}/chapter2/{uuid}") suspend fun fetchMangaContentPicture( @Path("path_word") pathWord: String, @Path("uuid") uuid: String, @Query("platform") platform: Int = 1, ): MangaContentDataModel @GET("/api/v3/topic/{name}") suspend fun getMangaTopicInfo( @Path("name") name: String, @Query("platform") platform: Int = 1, ): TopicInfoDataModelX @GET("/api/v3/topic/{name}/contents") suspend fun getMangaTopicList( @Path("name") name: String, @Query("type") type: Int, @Query("limit") limit: Int = 21, @Query("offset") offset: Int, @Query("platform") platform: Int = 1, ): TopicListDataModel @GET("/api/v3/topics") suspend fun fetchAllTopicListItem( @Query("type") type: Int = 1, @Query("limit") limit: Int = 21, @Query("offset") offset: Int, @Query("_update") update: Boolean = true, ): TopicAllListDataModel @POST("/api/v3/login") @FormUrlEncoded suspend fun login( @Field("username") username: String, @Field("password") passwordB64: String, @Field("salt") salt: Int, @Field("source") source: String = "freeSite", @Field("version") version: String = "2023.08.14", @Field("platform") platform: Int = 1, ): LoginDataModel @GET("/api/v3/member/browse/comics") suspend fun browsedComics( @Query("free_type") freeType: Int = 1, @Query("offset") offset: Int, @Query("limit") limit: Int = 20, @Query("_update") update: Boolean = true, ): WebHistoryDataModel @GET("/api/v3/member/collect/comics") suspend fun bookshelfWeb( @Query("free_type") freeType: Int = 1, @Query("limit") limit: Int = 21, @Query("offset") offset: Int, @Query("_update") update: Boolean = true, @Query("ordering") ordering: String = "-datetime_modifier", ): WebBookshelf @GET("/api/v3/member/update/info") suspend fun shortInfo( @Query("nickname") nickname: String = "", @Query("avatar") avatar: String = "", @Query("gender") gender: String = "", @Query("birthday") birthday: String = "", ): LoginInfoShortDataModel @GET("/api/v3/comic2/{word}/query") suspend fun comicWebHistory( @Path("word") word: String, @Query("platform") platform: Int = 1, @Query("_update") update: Boolean = true, ): WebComicHistory @GET("/api/v3/comics") suspend fun comicAuthors( @Query("free_type") freeType: Int = 1, @Query("author") author: String, @Query("limit") limit: Int = 100, @Query("offset") offset: Int, @Query("ordering") ordering: String = "-datetime_updated", ): AuthorsMangaDataModel @GET("/api/v3/comments") suspend fun comicComments( @Query("comic_id") comicID: String, @Query("limit") limit: Int = 20, @Query("offset") offset: Int, ): MangaCommentDataModel @GET("/api/v3/member/info") suspend fun loginInfo(): LoginInfoDataModel @FormUrlEncoded @POST("/api/v3/member/collect/comic") suspend fun comicCollect( @Field("comic_id") comicID: String, @Field("is_collect") isCollect: Int, @Field("_update") update: Boolean = true, ) @FormUrlEncoded @POST("/api/v3/member/comment") suspend fun commentPush( @Field("comic_id") comicId: String, @Field("comment") comment: String, @Field("reply_id") replyId: String = "", ): CommentPushDataModel } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/domin/DownloadFileDetectUtil.kt ================================================ package com.shicheeng.copymanga.domin import android.content.Context import android.os.Environment import androidx.core.net.toUri import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.PersonalInnerDataModel import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.util.KeyWordSwap import com.shicheeng.copymanga.util.asStringOrNull import com.shicheeng.copymanga.util.getOrNull import com.shicheeng.copymanga.util.nullWillBe import com.shicheeng.copymanga.util.parserAsJson import com.shicheeng.copymanga.util.transformToJsonObjectSafety import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import java.io.File import java.io.FileFilter import javax.inject.Inject import javax.inject.Singleton @Singleton class DownloadFileDetectUtil @Inject constructor( @ApplicationContext private val context: Context, private val mangaHistoryRepository: MangaHistoryRepository, ) { private val fileRootPath by lazy { File("${context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)}/${KeyWordSwap.SAVED_LOCAL_CHAPTER_NAME}") } private val fileRootPathV2 by lazy { context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) } private val allDownloadFiles by lazy { fileRootPathV2?.walk() } /** * 获取下载的地址,一般是下载主文件夹名字和漫画名字 * @param localSavableMangaModel 本地保存的漫画信息数据模型 */ fun getRootFile(localSavableMangaModel: LocalSavableMangaModel): File { return File( context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "/${localSavableMangaModel.mangaHistoryDataModel.name}" ) } /** * 通过[uuid]检测漫画章节是否下载。 * @param pathWord 空安全的漫画PathWord。 * @param uuid 空安全的漫画章节uuid。 */ // TODO: 章节检测不再使用本地暴力检测 suspend fun detectChapterDownloadedByUUID( pathWord: String?, uuid: String?, ): Boolean = runInterruptible(Dispatchers.IO) { if (!fileRootPath.exists()) { return@runInterruptible false } val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x -> x.asJsonObject["path_word"].asString == pathWord }?.asJsonObject if (json != null) { json.get("manga_downloaded") ?.asJsonArray ?.find { x -> x.asJsonObject["uuid"].asString == uuid } ?.isJsonNull == false } else { val jsonV2 = allDownloadFiles ?.filter { it.extension == "json" } ?.find { it.readText().parserAsJson().transformToJsonObjectSafety() ?.get("path_word")?.asString == pathWord }?.readText()?.parserAsJson()?.asJsonObject val chapterInJson = if (jsonV2?.has("chapters") == true) { jsonV2.get("chapters")?.asJsonObject } else null chapterInJson?.has(uuid) == true } } suspend fun detectMangaDownloadWithName( name: String, pathWord: String?, uuid: String, ): Boolean = withContext(Dispatchers.IO) { if (!fileRootPath.exists()) { return@withContext false } val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x -> x.asJsonObject["path_word"].asString == pathWord }?.asJsonObject if (json != null) { json.get("manga_downloaded") ?.asJsonArray ?.find { x -> x.asJsonObject["uuid"].asString == uuid } ?.isJsonNull == false } else { val mangaPath = File(fileRootPathV2, "/$name/${KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON}") if (!mangaPath.exists()) { return@withContext false } else { val jsonMangaIndex = mangaPath.readText().parserAsJson().transformToJsonObjectSafety() return@withContext jsonMangaIndex?.has(uuid) == true } } } suspend fun detectMangaDownloadWithChapterName( name: String, pathWord: String?, uuid: String, ): Boolean = withContext(Dispatchers.IO) { if (!fileRootPath.exists()) { return@withContext false } val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x -> x.asJsonObject["path_word"].asString == pathWord }?.asJsonObject if (json != null) { json.get("manga_downloaded") ?.asJsonArray ?.find { x -> x.asJsonObject["uuid"].asString == uuid } ?.isJsonNull == false } else { val mangaPath = File(fileRootPathV2, "/$name/${KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON}") if (!mangaPath.exists()) { return@withContext false } else { val jsonMangaIndex = mangaPath.readText().parserAsJson().transformToJsonObjectSafety() return@withContext jsonMangaIndex?.has(uuid) == true } } } /** * 找出下载过章节的漫画:通过读取[fileRootPath]和[fileRootPathV2]的文件。 */ fun findDownloadManga() = flow { val files = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?.listFiles() if (files == null) { emit(emptyList()) return@flow } val list = files .filter { it.isDirectory } .map { file -> val indexJson = File("${file.path}/${KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON}").takeIf { it.canRead() }?.readText()?.parserAsJson()?.transformToJsonObjectSafety() val coverPath = indexJson?.getOrNull("cover_entry")?.asString.nullWillBe { "cover.png" } PersonalInnerDataModel( name = file.name, url = ("${file.path}/$coverPath").toUri(), pathWord = findChapterPathWordWithName(file.name) ?: indexJson?.getOrNull("path_word")?.asStringOrNull ) } emit(list) } /** * 通过读取文件来获取漫画的pathWord。 * @param name 既是漫画名字也是文件夹的名字。 */ private fun findChapterPathWordWithName(name: String): String? { if (!fileRootPath.exists()) { return null } val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x -> x.asJsonObject["name"].asString == name }?.asJsonObject return json?.get("path_word")?.asString } suspend fun isChapterDownloadedWithStringList( pathWord: String?, uuid: String?, ): Boolean = runInterruptible(Dispatchers.IO) { if (!fileRootPath.exists()) { return@runInterruptible false } val json = fileRootPath.readText().parserAsJson().asJsonArray.find { x -> x.asJsonObject["path_word"].asString == pathWord }?.asJsonObject val jsonNewVersion = fileRootPathV2?.walk()?.filter { it.extension == "json" }?.find { x -> x.readText() .parserAsJson() .transformToJsonObjectSafety()?.get("path_word")?.asString == pathWord }?.readText()?.parserAsJson() ?.asJsonObject?.let { if (it.has("chapters")) it.get("chapters").asJsonObject else null } json?.get("manga_downloaded") ?.asJsonArray?.find { x -> x.asJsonObject["uuid"].asString == uuid } ?.isJsonNull == false || jsonNewVersion?.has(uuid) == true } //TODO : 完美的漫画本地检测 suspend fun ifChapterDownloaded( pathWord: String, uuid: String?, ): List? = runInterruptible(Dispatchers.IO) { if (!fileRootPath.exists() || fileRootPathV2?.exists() == false) { return@runInterruptible null } val json = File(fileRootPath, KeyWordSwap.SAVED_LOCAL_CHAPTER_NAME) .takeIf { it.exists() } ?.readText() ?.parserAsJson() ?.asJsonArray?.find { x -> x.asJsonObject["path_word"].asString == pathWord }?.asJsonObject if (json != null) { val mangaName = json["name"]?.asString val chapterName = json["manga_downloaded"]?.asJsonArray?.find { x -> x.asJsonObject["uuid"].asString == uuid }?.asJsonObject?.get("chapter_name")?.asString val savePath = "${context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)}/${mangaName}/${chapterName}" val file = File(savePath) buildList { file.listFiles(chaptersFileFilter)?.forEachIndexed { index, file -> add(MangaReaderPage(file.path, uuid, index)) } } } else { val jsonInner = allDownloadFiles ?.filter { it.extension == "json" } ?.find { it.readText().parserAsJson().transformToJsonObjectSafety() ?.get("path_word")?.asString == pathWord }?.readText()?.parserAsJson()?.asJsonObject ?: return@runInterruptible null if (!jsonInner.has("chapters") && !jsonInner.get("chapters").asJsonObject.has(uuid)) { return@runInterruptible null } else { val mangaName = jsonInner.get("name").asString val chapterName = jsonInner .get("chapters").asJsonObject .get(uuid).asJsonObject["chapter_name"].asString val file = File(fileRootPathV2, "${mangaName}/$chapterName") buildList { file.listFiles(chaptersFileFilter)?.forEachIndexed { index, fileInner -> add(MangaReaderPage(fileInner.path, uuid, index)) } } } } } private val chaptersFileFilter = FileFilter { it.extension == "jpg" || it.extension == "webp" || it.extension == "jpg" || it.extension == "jpeg" } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/error/ContinuationCallCallback.kt ================================================ package com.shicheeng.copymanga.error import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CompletionHandler import okhttp3.Call import okhttp3.Callback import okhttp3.Response import java.io.IOException import kotlin.coroutines.Continuation import kotlin.coroutines.resumeWithException class ContinuationCallCallback( private val call: Call, private val cancellation: CancellableContinuation, ) : Callback, CompletionHandler { override fun onFailure(call: Call, e: IOException) { if (!call.isCanceled() && cancellation.isActive) { cancellation.resumeWithException(e) } } override fun onResponse(call: Call, response: Response) { if (cancellation.isActive) { cancellation.resume(response) } } override fun invoke(cause: Throwable?) { runCatching { call.cancel() }.onFailure { cause?.addSuppressed(it) } } } fun Continuation.resume(value: T): Unit = resumeWith(Result.success(value)) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/error/DownloadErrorException.kt ================================================ package com.shicheeng.copymanga.error class DownloadErrorException( val comicPathWord: String, val chapterUUID: String, ) : Exception("下载错误") ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/error/EmptyJsonArray.kt ================================================ package com.shicheeng.copymanga.error class EmptyJsonArray:Exception() { } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/error/ErrorActivity.kt ================================================ package com.shicheeng.copymanga.error import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.core.view.WindowCompat import com.shicheeng.copymanga.app.AppAttachCompatActivity import com.shicheeng.copymanga.ui.screen.error.ErrorScreen import com.shicheeng.copymanga.ui.theme.CopyMangaTheme class ErrorActivity : AppAttachCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) val message = intent?.getStringExtra(ERROR_MESSAGE) setContent { CopyMangaTheme { ErrorScreen(message = message) { finish() } } } } companion object { fun newIntentInstance( context: Context, errorMessage: String?, ): Intent { val intent = Intent(context, ErrorActivity::class.java) intent.putExtra(ERROR_MESSAGE, errorMessage) return intent } private const val ERROR_MESSAGE = "ERROR_MESSAGE" } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/delegate/IdlingDelegate.kt ================================================ package com.shicheeng.copymanga.fm.delegate import android.os.Handler import android.os.Looper import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import java.util.concurrent.TimeUnit /** * 大部分代码来自Kotatsu。 */ class IdlingDelegate(private val idleCallback: IdleCallback) : DefaultLifecycleObserver { private val handler = Handler(Looper.getMainLooper()) private val idleRunnable = Runnable { idleCallback.onIdle() } fun bindToLifecycle(owner: LifecycleOwner) { owner.lifecycle.addObserver(this) } fun onUserInteraction() { handler.removeCallbacks(idleRunnable) handler.postDelayed(idleRunnable, TimeUnit.SECONDS.toMillis(10)) } override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) owner.lifecycle.removeObserver(this) handler.removeCallbacks(idleRunnable) } fun interface IdleCallback { fun onIdle() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/ChapterLoader.kt ================================================ package com.shicheeng.copymanga.fm.domain import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.domin.DownloadFileDetectUtil import com.shicheeng.copymanga.resposity.MangaInfoRepository import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject @ViewModelScoped class ChapterLoader @Inject constructor( private val fileDetectUtil: DownloadFileDetectUtil, private val repository: MangaInfoRepository, ) { val chapters = LinkedHashMap() val nextChapterLoadingState = MutableStateFlow(NextChapterLoadState.NotLoading) private val chapterPage = ChapterPages() private val mutex = Mutex() suspend fun init(list: List?) = mutex.withLock { chapters.clear() list?.forEach { chapters[it.uuid] = it } } suspend fun loadPrevNextChapter( list: List?, uuid: String?, isNext: Boolean, ) { nextChapterLoadingState.emit(NextChapterLoadState.Loading) val chapters = list ?: return val predicate: (LocalChapter) -> Boolean = { it.uuid == uuid } val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate) if (index == -1) return val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return try { val newPages = loadChapter(newChapter.comicPathWord, newChapter.uuid) mutex.withLock { if (chapterPage.chapterSize > 1) { if (chapterPage.size > 130) { if (isNext) { chapterPage.removeFirst() } else { chapterPage.removeLast() } } } if (isNext) { chapterPage.addLast(newChapter.uuid, newPages) } else { chapterPage.addFirst(newChapter.uuid, newPages) } nextChapterLoadingState.emit(NextChapterLoadState.NotLoading) } } catch (e: Exception) { nextChapterLoadingState.emit(NextChapterLoadState.Error(e)) } } suspend fun loadSingleChapter(pathWord: String, uuid: String) { val page = loadChapter(pathWord, uuid) mutex.withLock { chapterPage.clear() chapterPage.addLast(uuid, page) } } fun getPage(uuid: String): List { return chapterPage.subList(uuid) } operator fun get(uuid: String): Int { return chapterPage.size(uuid) } fun snapshot() = chapterPage.toList() fun last() = chapterPage.last() fun first() = chapterPage.first() val size get() = chapters.size private suspend fun loadChapter(pathWord: String, uui: String): List { val chapter = checkNotNull(chapters[uui]) { "NO CHAPTER FOUND" } val isDownload = fileDetectUtil.isChapterDownloadedWithStringList(pathWord, chapter.uuid) val listLocal = if (isDownload) fileDetectUtil.ifChapterDownloaded(pathWord, chapter.uuid) else null return repository.fetchContentMayLocal(listLocal, pathWord, chapter.uuid) } sealed class NextChapterLoadState { object Loading : NextChapterLoadState() data class Error(val e: Throwable) : NextChapterLoadState() object NotLoading : NextChapterLoadState() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/ChapterPages.kt ================================================ package com.shicheeng.copymanga.fm.domain import com.shicheeng.copymanga.data.MangaReaderPage /** * Copy from Kotatsu * * The class was _reformed_ for this App */ class ChapterPages private constructor(private val pages: ArrayDeque) : List by pages { private val indices = LinkedHashMap() constructor() : this(ArrayDeque()) val chapterSize: Int get() = indices.size fun removeFirst() { val chapterId = pages.first().uuid indices.remove(chapterId) var delta = 0 while (pages.first().uuid == chapterId) { pages.removeFirst() delta-- } shiftIndices(delta) } fun removeLast() { val chapterId = pages.last().uuid indices.remove(chapterId) while (pages.last().uuid == chapterId) { pages.removeLast() } } fun addLast(id: String, newPages: List) { indices[id] = pages.size until (pages.size + newPages.size) pages.addAll(newPages) } fun addFirst(id: String, newPages: List) { shiftIndices(newPages.size) indices[id] = newPages.indices pages.addAll(0, newPages) } fun clear() { indices.clear() pages.clear() } fun size(id: String) = indices[id]?.run { endInclusive - start + 1 } ?: 0 fun subList(id: String): List { val range = indices[id] ?: return emptyList() return pages.subList(range.first, range.last + 1) } private fun shiftIndices(delta: Int) { indices.forEach { (t, u) -> indices[t] = u + delta } } private operator fun IntRange.plus(delta: Int): IntRange { return IntRange(start + delta, endInclusive + delta) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/PageHolderDelegate.kt ================================================ package com.shicheeng.copymanga.fm.domain import android.net.Uri import androidx.core.net.toUri import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.plus import java.io.File import java.io.IOException class PageHolderDelegate( private val loader: PagerLoader, private val callback: Callback, ) : DefaultOnImageEventListener { private var job: Job? = null private val scope = loader.loaderScope + Dispatchers.Main.immediate private var state = State.EMPTY private var file: File? = null fun onBind(url: String) { val prevJop = job job = scope.launch { prevJop?.cancelAndJoin() doLoad(url, false) } } fun retry(url: String) { val prevJob = job job = scope.launch { prevJob?.cancelAndJoin() doLoad(url, true) } } fun onRecycler() { state = State.EMPTY file = null job?.cancel() } override fun onReady() { super.onReady() state = State.SHOWING callback.onImageShowing() } override fun onImageLoaded() { super.onImageLoaded() state = State.SHOWN callback.onImageShown() } override fun onImageLoadError(e: Throwable) { val file = this.file if (state == State.LOADED && e is IOException && file != null && file.exists()) { tryConvert(file, e) } else { state = State.ERROR callback.onError(e = e) } callback.onError(e) } private suspend fun doLoad(url: String, force: Boolean) { state = State.LOADING callback.onLoadingStarted() try { val task = loader.loadImageFromUrlAsync(url, force) file = coroutineScope { task.await() } state = State.LOADED callback.onImageReady(checkNotNull(file).toUri()) } catch (e: Exception) { state = State.ERROR callback.onError(e) } } private fun tryConvert(file: File, e: Exception) { val prevJob = job job = scope.launch { prevJob?.join() state = State.CONVERTING try { loader.convertInPlace(file) state = State.CONVERTED callback.onImageReady(file.toUri()) } catch (ce: CancellationException) { throw ce } catch (e2: Throwable) { e.addSuppressed(e2) state = State.ERROR callback.onError(e = e) } } } private enum class State { EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR } interface Callback { fun onLoadingStarted() fun onError(e: Throwable) fun onImageReady(uri: Uri) fun onImageShowing() fun onImageShown() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/PagerCache.kt ================================================ package com.shicheeng.copymanga.fm.domain import android.content.Context import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.tomclaw.cache.DiskLruCache import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream import java.io.OutputStream import javax.inject.Inject import javax.inject.Singleton @Singleton class PagerCache @Inject constructor( @ApplicationContext context: Context, settingPref: SettingPref, ) { private val cache = (context.externalCacheDirs + context.cacheDir).firstNotNullOfOrNull { it.makeDirIfNoExist() }.let { file -> checkNotNull(file) { val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") { it.absolutePath } "Cannot find directory for PagesCache: [$dirs]" } } private val lruCache = createDiskLruCacheSafe( dir = cache, size = FileSize.MEGABYTES.convert(settingPref.cacheSize.toLong(), FileSize.BYTES), ) operator fun get(url: String): File? { return lruCache.get(url)?.takeIfReadable() } suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) { val file = File(cache.parentFile, url.longHashCode().toString()) try { file.outputStream().use { out -> inputStream.copyToSuspending(out) } lruCache.put(url, file) } finally { file.delete() } } private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache { return try { DiskLruCache.create(dir, size) } catch (e: Exception) { dir.deleteRecursively() dir.mkdir() DiskLruCache.create(dir, size) } } /** * Copy from kotatsu */ private suspend fun InputStream.copyToSuspending( out: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE, ): Long = withContext(Dispatchers.IO) { val job = currentCoroutineContext()[Job] var bytesCopied: Long = 0 val buffer = ByteArray(bufferSize) var bytes = read(buffer) while (bytes >= 0) { out.write(buffer, 0, bytes) bytesCopied += bytes job?.ensureActive() bytes = read(buffer) job?.ensureActive() } bytesCopied } /** * Come from Kotatsu */ private fun String.longHashCode(): Long { var h = 1125899906842597L val len: Int = this.length for (i in 0 until len) { h = 31 * h + this[i].code } return h } /** * Come from Kotatsu */ private fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } } fun File.makeDirIfNoExist(): File { if (!this.exists()) { if (this.parentFile?.exists() != true) this.parentFile?.mkdir() if (this.parentFile?.canWrite() != true) this.parentFile?.canWrite() this.mkdir() } return this } /** * * Copy from Kotatsu */ enum class FileSize(private val multiplier: Int) { BYTES(1), KILOBYTES(1024), MEGABYTES(1024 * 1024); fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/domain/PagerLoader.kt ================================================ package com.shicheeng.copymanga.fm.domain import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import androidx.collection.LongSparseArray import androidx.collection.set import com.shicheeng.copymanga.util.RetainedLifecycleCoroutineScope import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.lifecycle.RetainedLifecycle import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.invoke import kotlinx.coroutines.plus import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.InputStream import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @ActivityRetainedScoped class PagerLoader @Inject constructor( lifecycle: ActivityRetainedLifecycle, private val cache: PagerCache, private val headers: Headers, private val okHttpClient: OkHttpClient, ) : RetainedLifecycle.OnClearedListener { val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default private val tasks = LongSparseArray>() private val prefetchQueue = LinkedList() private val counter = AtomicInteger(0) private val convertLock = Mutex() init { lifecycle.addOnClearedListener(this) } private fun onIdle() { synchronized(prefetchQueue) { while (prefetchQueue.isNotEmpty()) { val url = prefetchQueue.pollFirst() ?: return if (cache[url] == null) { synchronized(tasks) { tasks[url.hashCode().toLong()] = loadPageAsync(url) } return } } } } fun loadImageFromUrlAsync(url: String, force: Boolean): Deferred { if (!force) { cache[url]?.let { return getCompletedTaskAsync(it) } } var task = tasks[url.hashCode().toLong()] if (force) { task?.cancel() } else if (task?.isCancelled == false) { return task } task = loadPageAsync(url) synchronized(tasks) { tasks[url.hashCode().toLong()] = task } return task } private fun loadPageAsync(url: String): Deferred { val deferred = loaderScope.async { try { loadPagePicBitmap(url) } finally { if (counter.decrementAndGet() == 0) { onIdle() } } } return deferred } suspend fun convertInPlace(file: File) { convertLock.withLock { runInterruptible(Dispatchers.Default) { val image = BitmapFactory.decodeFile(file.absolutePath) try { file.outputStream().use { out -> image.compress(Bitmap.CompressFormat.PNG, 100, out) } } finally { image.recycle() } } } } private suspend fun loadPagePicBitmap(url: String): File = Dispatchers.IO { val uri = Uri.parse(url) if (uri.scheme == "https") { val request = Request.Builder() .headers(headers).url(url).get() .build() okHttpClient.newCall(request).execute().use { res -> val ins = checkNotNull(res.body).byteStream() cache.put(url, ins) } } else { val input: InputStream = File(url).inputStream() cache.put(url, input) } } private fun getCompletedTaskAsync(file: File): Deferred { return CompletableDeferred(file) } private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { exception.printStackTrace() } } override fun onCleared() { synchronized(tasks) { tasks.clear() } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/BaseReader.kt ================================================ package com.shicheeng.copymanga.fm.reader import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.viewbinding.ViewBinding import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.MangaState import com.shicheeng.copymanga.util.observe import com.shicheeng.copymanga.viewmodel.ReaderViewModel abstract class BaseReader : Fragment() { private var _binding: VB? = null protected val viewModel by activityViewModels() protected val binding: VB get() = checkNotNull(_binding) protected var readerAdapter: BaseReaderAdapter<*>? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { val binding = onCreateViewInflater(inflater, container) _binding = binding return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { readerAdapter = createAdapter() viewModel.mangaContent.observe(viewLifecycleOwner) { onLoadUrlChangeSuccess(it.list, it.state) } super.onViewCreated(view, savedInstanceState) } override fun onDestroyView() { _binding = null readerAdapter = null super.onDestroyView() } protected fun bindingOrNull() = _binding protected abstract fun onCreateViewInflater(inflater: LayoutInflater, container: ViewGroup?): VB protected abstract suspend fun onLoadUrlChangeSuccess( list: List, state: MangaState?, ) protected fun requireBinding() = requireNotNull(_binding) { "NO BIND VIEW HERE" } protected fun requireAdapter() = checkNotNull(readerAdapter) { "NO ADAPTER HERE" } protected abstract fun createAdapter(): BaseReaderAdapter<*> abstract fun currentState(): MangaState? abstract fun moveToPosition(position: Int, smooth: Boolean) abstract fun moveDelta(delta: Int) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/BaseReaderAdapter.kt ================================================ package com.shicheeng.copymanga.fm.reader import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.shicheeng.copymanga.data.MangaReaderPage import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @Suppress("LeakingThis") abstract class BaseReaderAdapter> : RecyclerView.Adapter() { private val diff = AsyncListDiffer(this, DIffCallBack()) init { stateRestorationPolicy = StateRestorationPolicy.PREVENT } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = onCreateViewHolder(parent) override fun onBindViewHolder(holder: VH, position: Int) { holder.bind(url = diff.currentList[position].url) } open fun getItem(position: Int): MangaReaderPage = diff.currentList[position] open fun getItemOrNull(position: Int): MangaReaderPage? = diff.currentList.getOrNull(position) override fun getItemCount(): Int = diff.currentList.size suspend fun subItems(list: List) = suspendCoroutine { continuation -> diff.submitList(list) { continuation.resume(Unit) } } override fun onViewRecycled(holder: VH) { holder.onRecycler() super.onViewRecycled(holder) } protected abstract fun onCreateViewHolder(parent: ViewGroup): VH private class DIffCallBack : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: MangaReaderPage, newItem: MangaReaderPage, ): Boolean = oldItem === newItem override fun areContentsTheSame( oldItem: MangaReaderPage, newItem: MangaReaderPage, ): Boolean = oldItem == newItem } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/BaseReaderViewHolder.kt ================================================ package com.shicheeng.copymanga.fm.reader import android.content.Context import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding import com.shicheeng.copymanga.databinding.LayoutImageLoadBinding import com.shicheeng.copymanga.fm.domain.PageHolderDelegate import com.shicheeng.copymanga.fm.domain.PagerLoader @Suppress("LeakingThis") abstract class BaseReaderViewHolder( protected val binding: VB, imageLoader: PagerLoader, ) : ViewHolder(binding.root), PageHolderDelegate.Callback { val context: Context get() = itemView.context protected val bindingInfo = LayoutImageLoadBinding.bind(binding.root) protected val delegate = PageHolderDelegate(imageLoader, this) fun bind(url: String) { delegate.onBind(url) onBind(url) } open fun onRecycler() { delegate.onRecycler() } abstract fun onBind(url: String) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/MangaLoader.kt ================================================ package com.shicheeng.copymanga.fm.reader import androidx.lifecycle.SavedStateHandle class MangaLoader( savedStateHandle: SavedStateHandle, ) { val mangaPathWord = savedStateHandle.get(MANGA_PATH_WORD) ?: NONE val mangaChapterUUID = savedStateHandle.get(MANGA_UUID) ?: CHAPTER_NONE companion object { const val MANGA_PATH_WORD = "MANGA_PATH_WORD" const val MANGA_UUID = "MANGA_UUID" const val NONE = "NONE" const val CHAPTER_NONE = "CHAPTER_NONE" } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/ReaderManager.kt ================================================ package com.shicheeng.copymanga.fm.reader import androidx.annotation.IdRes import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit import com.shicheeng.copymanga.R import com.shicheeng.copymanga.fm.reader.noraml.ReaderPageFragment import com.shicheeng.copymanga.fm.reader.standard.ReaderPagerStandardFragment import com.shicheeng.copymanga.fm.reader.webtoon.WebtoonReaderFragment import java.util.EnumMap class ReaderManager( private val supportFragmentManager: FragmentManager, @IdRes private val containerId: Int, ) { private val modeMap = EnumMap>>(ReaderMode::class.java) init { modeMap[ReaderMode.NORMAL] = ReaderPageFragment::class.java modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java modeMap[ReaderMode.STANDARD] = ReaderPagerStandardFragment::class.java } val currentReader: BaseReader<*>? get() = supportFragmentManager.findFragmentById(containerId) as? BaseReader<*> val currentReaderMode: ReaderMode? get() { val readerClass = currentReader?.javaClass ?: return null return modeMap.entries.find { it.value == readerClass }?.key } fun replace(newMode: ReaderMode) { val readerClass = requireNotNull(modeMap[newMode]) supportFragmentManager.commit { setReorderingAllowed(true) replace(containerId, readerClass, null, null) } } fun replace(reader: BaseReader<*>) { supportFragmentManager.commit { setReorderingAllowed(true) replace(containerId, reader) } } } enum class ReaderMode(@IdRes val id: Int) { NORMAL(R.string.japanese_r_to_l), WEBTOON(R.string.korea_chinese_top_to_bottom), STANDARD(R.string.manga_mode_l_t_r); companion object { fun idOf(id: Int?) = values().firstOrNull { it.id == id } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/PageSliderFormatter.kt ================================================ package com.shicheeng.copymanga.fm.reader.noraml import com.google.android.material.slider.LabelFormatter class PageSliderFormatter : LabelFormatter { override fun getFormattedValue(value: Float): String { return (value + 1).toInt().toString() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/ReaderPageAdapter.kt ================================================ package com.shicheeng.copymanga.fm.reader.noraml import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import com.shicheeng.copymanga.databinding.ItemPageBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter class ReaderPageAdapter(private val owner: LifecycleOwner, private val imageLoader: PagerLoader) : BaseReaderAdapter() { override fun onCreateViewHolder(parent: ViewGroup): ReaderPageViewHolder = ReaderPageViewHolder( ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), imageLoader, owner ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/ReaderPageFragment.kt ================================================ package com.shicheeng.copymanga.fm.reader.noraml import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.google.android.material.snackbar.Snackbar import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.MangaState import com.shicheeng.copymanga.databinding.FragmentReaderNormalBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReader import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter import com.shicheeng.copymanga.util.onPageChangeCallback import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.yield import javax.inject.Inject @AndroidEntryPoint open class ReaderPageFragment : BaseReader() { @Inject lateinit var pagerLoader: PagerLoader override fun onCreateViewInflater( inflater: LayoutInflater, container: ViewGroup?, ): FragmentReaderNormalBinding = FragmentReaderNormalBinding.inflate(inflater, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.mangaReaderViewpager2.layoutDirection = View.LAYOUT_DIRECTION_RTL with(binding.mangaReaderViewpager2) { adapter = readerAdapter offscreenPageLimit = 1 onPageChangeCallback { viewModel.onPagePositionChange(it) } } } override fun onDestroyView() { requireBinding().mangaReaderViewpager2.adapter = null super.onDestroyView() } override fun moveToPosition(position: Int, smooth: Boolean) { binding.mangaReaderViewpager2.setCurrentItem(position, smooth) } override fun currentState(): MangaState? = bindingOrNull()?.run { val adapter = mangaReaderViewpager2.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(mangaReaderViewpager2.currentItem) ?: return@run null MangaState(page.uuid ?: return@run null, page.index) } override fun moveDelta(delta: Int) { binding.mangaReaderViewpager2.currentItem = binding.mangaReaderViewpager2.currentItem + delta } override suspend fun onLoadUrlChangeSuccess( list: List, state: MangaState?, ) = coroutineScope { val items = async { requireAdapter().subItems(list) yield() } if (state != null) { val position = list.indexOfFirst { it.uuid == state.uuid && it.index == state.page } items.await() if (position != -1) { binding.mangaReaderViewpager2.setCurrentItem(position, false) viewModel.onPagePositionChange(position) } else { Snackbar.make(requireView(), getString(R.string.no_content), Snackbar.LENGTH_LONG) .show() } } else { items.await() } } override fun createAdapter(): BaseReaderAdapter<*> { return ReaderPageAdapter( owner = viewLifecycleOwner, imageLoader = pagerLoader ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/noraml/ReaderPageViewHolder.kt ================================================ package com.shicheeng.copymanga.fm.reader.noraml import android.annotation.SuppressLint import android.net.Uri import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.ImageSource import com.shicheeng.copymanga.databinding.ItemPageBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReaderViewHolder @SuppressLint("ClickableViewAccessibility") class ReaderPageViewHolder( binding: ItemPageBinding, imageLoader: PagerLoader, owner: LifecycleOwner, ) : BaseReaderViewHolder(binding, imageLoader) { private var url: String? = null init { binding.bivPager.bindToLifecycle(owner) binding.bivPager.addOnImageEventListener(delegate) } override fun onBind(url: String) { this.url = url } override fun onLoadingStarted() { binding.errorLayout.errorTextLayout.isVisible = false bindingInfo.loadIndicator.isVisible = true binding.bivPager.recycle() } override fun onError(e: Throwable) { e.printStackTrace() with(binding.errorLayout) { errorTextLayout.isVisible = true errorTextTipDesc.text = e.message btnErrorRetry.setOnClickListener { url?.let { it1 -> delegate.retry(it1) } } } bindingInfo.loadIndicator.isVisible = false } override fun onImageReady(uri: Uri) { binding.bivPager.setImage(ImageSource.Uri(uri)) } override fun onImageShowing() { binding.bivPager.maxScale = 2f * maxOf( binding.bivPager.width / binding.bivPager.sWidth.toFloat(), binding.bivPager.height / binding.bivPager.sHeight.toFloat(), ) } override fun onImageShown() { bindingInfo.loadIndicator.isVisible = false binding.errorLayout.errorTextLayout.isVisible = false } override fun onRecycler() { super.onRecycler() binding.bivPager.recycle() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/standard/ReaderPagerStandardFragment.kt ================================================ package com.shicheeng.copymanga.fm.reader.standard import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.google.android.material.snackbar.Snackbar import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.MangaState import com.shicheeng.copymanga.databinding.FragmentReaderNormalBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReader import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter import com.shicheeng.copymanga.fm.reader.noraml.ReaderPageAdapter import com.shicheeng.copymanga.util.onPageChangeCallback import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.yield import javax.inject.Inject @AndroidEntryPoint class ReaderPagerStandardFragment : BaseReader() { @Inject lateinit var pagerLoader: PagerLoader override fun onCreateViewInflater( inflater: LayoutInflater, container: ViewGroup?, ): FragmentReaderNormalBinding { return FragmentReaderNormalBinding.inflate(inflater, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.mangaReaderViewpager2.layoutDirection = View.LAYOUT_DIRECTION_LTR with(binding.mangaReaderViewpager2) { adapter = readerAdapter offscreenPageLimit = 1 onPageChangeCallback { viewModel.onPagePositionChange(it) } } } override suspend fun onLoadUrlChangeSuccess(list: List, state: MangaState?) { coroutineScope { val items = async { requireAdapter().subItems(list) yield() } if (state != null) { val position = list.indexOfFirst { it.uuid == state.uuid && it.index == state.page } items.await() if (position != -1) { binding.mangaReaderViewpager2.setCurrentItem(position, false) viewModel.onPagePositionChange(position) } else { Snackbar.make( requireView(), getString(R.string.no_content), Snackbar.LENGTH_LONG ) .show() } } else { items.await() } } } override fun createAdapter(): BaseReaderAdapter<*> { return ReaderPageAdapter( owner = viewLifecycleOwner, imageLoader = pagerLoader ) } override fun currentState(): MangaState? = bindingOrNull()?.run { val adapter = mangaReaderViewpager2.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(mangaReaderViewpager2.currentItem) ?: return@run null MangaState(page.uuid ?: return@run null, page.index) } override fun moveToPosition(position: Int, smooth: Boolean) { binding.mangaReaderViewpager2.setCurrentItem( position, smooth ) } override fun moveDelta(delta: Int) { binding.mangaReaderViewpager2.currentItem = binding.mangaReaderViewpager2.currentItem + 1 } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonFrameLayout.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout import androidx.annotation.AttrRes import com.shicheeng.copymanga.R class WebtoonFrameLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { private val target by lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.biv_pager_webtoon) } fun dispatchVerticalScroll(dy: Int): Int { if (dy == 0) { return 0 } val oldScroll = target.getScroll() target.scrollBy(dy) return target.getScroll() - oldScroll } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonImageView.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.content.Context import android.graphics.PointF import android.util.AttributeSet import android.view.View import android.view.ViewParent import androidx.recyclerview.widget.RecyclerView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView private const val SCROLL_UNKNOWN = -1 class WebtoonImageView @JvmOverloads constructor( context: Context, attr: AttributeSet? = null, ) : SubsamplingScaleImageView(context, attr) { private val ct = PointF() private var scrollPos = 0 private var scrollRange = SCROLL_UNKNOWN fun scrollBy(delta: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { return } val newScroll = scrollPos + delta scrollToInternal(newScroll.coerceIn(0, maxScroll)) } fun scrollTo(y: Int) { val maxScroll = getScrollRange() if (maxScroll == 0) { resetScaleAndCenter() return } scrollToInternal(y.coerceIn(0, maxScroll)) } fun getScroll() = scrollPos fun getScrollRange(): Int { if (scrollRange == SCROLL_UNKNOWN) { computeScrollRange() } return scrollRange.coerceAtLeast(0) } override fun recycle() { scrollRange = SCROLL_UNKNOWN scrollPos = 0 super.recycle() } override fun getSuggestedMinimumHeight(): Int { var desiredHeight = super.getSuggestedMinimumHeight() if (sHeight == 0) { val parentHeight = parentHeight() if (desiredHeight < parentHeight) { desiredHeight = parentHeight } } return desiredHeight } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec) val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec) val parentWidth = MeasureSpec.getSize(widthMeasureSpec) val parentHeight = MeasureSpec.getSize(heightMeasureSpec) val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY var width = parentWidth var height = parentHeight if (sWidth > 0 && sHeight > 0) { if (resizeWidth && resizeHeight) { width = sWidth height = sHeight } else if (resizeHeight) { height = (sHeight.toDouble() / sWidth.toDouble() * width).toInt() } else if (resizeWidth) { width = (sWidth.toDouble() / sHeight.toDouble() * height).toInt() } } width = width.coerceAtLeast(suggestedMinimumWidth) height = height.coerceIn(suggestedMinimumHeight, parentHeight()) setMeasuredDimension(width, height) } private fun scrollToInternal(pos: Int) { scrollPos = pos ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) setScaleAndCenter(minScale, ct) } private fun computeScrollRange() { if (!isReady) { return } val totalHeight = (sHeight * minScale).toIntUp() scrollRange = (totalHeight - height).coerceAtLeast(0) } private fun parentHeight(): Int { return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 } } fun Float.toIntUp(): Int { val intValue = toInt() return if (this == intValue.toFloat()) { intValue } else { intValue + 1 } } private val View.parents: Sequence get() = sequence { var p: ViewParent? = parent while (p != null) { yield(p) p = p.parent } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonLayoutManager.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.content.Context import android.util.AttributeSet import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.sign /** * From Kotatsu Reader */ @Suppress("unused") class WebtoonLayoutManager : LinearLayoutManager { private var scrollDirection: Int = 0 constructor(context: Context) : super(context) constructor( context: Context, orientation: Int, reverseLayout: Boolean, ) : super(context, orientation, reverseLayout) constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int, ) : super(context, attrs, defStyleAttr, defStyleRes) override fun scrollVerticallyBy( dy: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State, ): Int { scrollDirection = dy.sign return super.scrollVerticallyBy(dy, recycler, state) } override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) { if (state.hasTargetScrollPosition()) { super.calculateExtraLayoutSpace(state, extraLayoutSpace) return } val pageSize = height extraLayoutSpace[0] = if (scrollDirection < 0) pageSize else 0 extraLayoutSpace[1] = if (scrollDirection < 0) 0 else pageSize } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonReaderAdapter.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.view.LayoutInflater import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import com.shicheeng.copymanga.databinding.ItemPageWebtoonBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter class WebtoonReaderAdapter( private val owner: LifecycleOwner, private val imageLoader: PagerLoader, ) : BaseReaderAdapter() { override fun onCreateViewHolder(parent: ViewGroup): WebtoonReaderViewHolder = WebtoonReaderViewHolder( ItemPageWebtoonBinding.inflate( LayoutInflater.from(parent.context), parent, false ), imageLoader, owner ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonReaderFragment.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.MangaState import com.shicheeng.copymanga.databinding.FragmentReaderWebtoonBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReader import com.shicheeng.copymanga.fm.reader.BaseReaderAdapter import com.shicheeng.copymanga.util.findCurrentPagePosition import com.shicheeng.copymanga.util.firstVisibleItemPosition import com.shicheeng.copymanga.util.setFirstVisibleItemPositionSmooth import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.yield import javax.inject.Inject @AndroidEntryPoint class WebtoonReaderFragment : BaseReader() { private val scrollInterpolator = AccelerateDecelerateInterpolator() @Inject lateinit var pagerLoader: PagerLoader override fun onCreateViewInflater( inflater: LayoutInflater, container: ViewGroup?, ): FragmentReaderWebtoonBinding = FragmentReaderWebtoonBinding.inflate(inflater, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(binding.mangaReaderWebtoonRecyclerview) { setHasFixedSize(true) adapter = readerAdapter addOnScrollListener(RecyclerViewScrollListener()) } } override fun currentState(): MangaState? = bindingOrNull()?.run { val firstItem = mangaReaderWebtoonRecyclerview.findCurrentPagePosition() val adapter = mangaReaderWebtoonRecyclerview.adapter as? BaseReaderAdapter<*> val page = adapter?.getItemOrNull(firstItem) ?: return@run null MangaState(page.uuid ?: return@run null, page.index) } override fun onDestroyView() { requireBinding().mangaReaderWebtoonRecyclerview.adapter = null super.onDestroyView() } override fun moveToPosition(position: Int, smooth: Boolean) { binding.mangaReaderWebtoonRecyclerview.setFirstVisibleItemPositionSmooth( position, smooth ) } override fun moveDelta(delta: Int) { binding.mangaReaderWebtoonRecyclerview.smoothScrollBy( 0, (binding.mangaReaderWebtoonRecyclerview.height * 0.9).toInt() * delta, scrollInterpolator, ) } override suspend fun onLoadUrlChangeSuccess( list: List, state: MangaState?, ) = coroutineScope { val items = async { requireAdapter().subItems(list) yield() } if (state != null) { val position = list.indexOfFirst { it.uuid == state.uuid && it.index == state.page } items.await() if (position != -1) { with(binding.mangaReaderWebtoonRecyclerview) { firstVisibleItemPosition = position } viewModel.onPagePositionChange(position) } else { Snackbar.make(requireView(), getString(R.string.no_content), Snackbar.LENGTH_LONG) .show() } } else { items.await() } } override fun createAdapter(): BaseReaderAdapter<*> { return WebtoonReaderAdapter( owner = viewLifecycleOwner, imageLoader = pagerLoader ) } inner class RecyclerViewScrollListener : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) viewModel.onPagePositionChange(recyclerView.findCurrentPagePosition()) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonReaderViewHolder.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.net.Uri import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import com.shicheeng.copymanga.databinding.ItemPageWebtoonBinding import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.BaseReaderViewHolder class WebtoonReaderViewHolder( itemPageWebtoonBinding: ItemPageWebtoonBinding, imageLoader: PagerLoader, owner: LifecycleOwner, ) : BaseReaderViewHolder( binding = itemPageWebtoonBinding, imageLoader = imageLoader ) { private var url: String? = null private var scrollToRestore = 0 init { binding.bivPagerWebtoon.bindToLifecycle(owner) binding.bivPagerWebtoon.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory() binding.bivPagerWebtoon.addOnImageEventListener(delegate) } override fun onBind(url: String) { this.url = url } override fun onLoadingStarted() { binding.errorLayout.errorTextLayout.isVisible = false bindingInfo.loadIndicator.isVisible = true binding.bivPagerWebtoon.recycle() } override fun onError(e: Throwable) { with(binding.errorLayout) { errorTextLayout.isVisible = true errorTextTipDesc.text = e.message btnErrorRetry.setOnClickListener { url?.let { it1 -> delegate.retry(it1) } } } bindingInfo.loadIndicator.isVisible = false } override fun onImageReady(uri: Uri) { binding.bivPagerWebtoon.setImage(ImageSource.Uri(uri)) } override fun onImageShowing() { with(binding.bivPagerWebtoon) { minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minScale = width / sWidth.toFloat() maxScale = minScale scrollTo( when { scrollToRestore != 0 -> scrollToRestore itemView.top < 0 -> getScrollRange() else -> 0 }, ) scrollToRestore = 0 } } override fun onImageShown() { bindingInfo.loadIndicator.isVisible = false binding.errorLayout.errorTextLayout.isVisible = false } override fun onRecycler() { super.onRecycler() binding.bivPagerWebtoon.recycle() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonRecyclerView.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.content.Context import android.util.AttributeSet import androidx.core.view.ViewCompat.TYPE_TOUCH import androidx.recyclerview.widget.RecyclerView import com.shicheeng.copymanga.util.findCurrentPagePosition import java.util.LinkedList class WebtoonRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : RecyclerView(context, attrs, defStyleAttr) { private var onPageScrollListeners: MutableList? = null override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH) override fun startNestedScroll(axes: Int, type: Int): Boolean = true override fun dispatchNestedPreScroll( dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, ) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH) override fun dispatchNestedPreScroll( dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int, ): Boolean { val consumedY = consumeVerticalScroll(dy) if (consumed != null) { consumed[0] = 0 consumed[1] = consumedY } notifyScrollChanged(dy) return consumedY != 0 || dy == 0 } private fun consumeVerticalScroll(dy: Int): Int { if (childCount == 0) { return 0 } when { dy > 0 -> { val child = getChildAt(0) as WebtoonFrameLayout var consumedByChild = child.dispatchVerticalScroll(dy) if (consumedByChild < dy) { if (childCount > 1) { val nextChild = getChildAt(1) as WebtoonFrameLayout val unconsumed = dy - consumedByChild - nextChild.top //will be consumed by scroll if (unconsumed > 0) { consumedByChild += nextChild.dispatchVerticalScroll(unconsumed) } } } return consumedByChild } dy < 0 -> { val child = getChildAt(childCount - 1) as WebtoonFrameLayout var consumedByChild = child.dispatchVerticalScroll(dy) if (consumedByChild > dy) { if (childCount > 1) { val nextChild = getChildAt(childCount - 2) as WebtoonFrameLayout val unconsumed = dy - consumedByChild + (height - nextChild.bottom) //will be consumed by scroll if (unconsumed < 0) { consumedByChild += nextChild.dispatchVerticalScroll(unconsumed) } } } return consumedByChild } } return 0 } fun addOnPageScrollListener(listener: OnPageScrollListener) { val list = onPageScrollListeners ?: LinkedList().also { onPageScrollListeners = it } list.add(listener) } fun removeOnPageScrollListener(listener: OnPageScrollListener) { onPageScrollListeners?.remove(listener) } private fun notifyScrollChanged(dy: Int) { val listeners = onPageScrollListeners if (listeners.isNullOrEmpty()) { return } val centerPosition = findCurrentPagePosition() listeners.forEach { it.dispatchScroll(this, dy, centerPosition) } } abstract class OnPageScrollListener { private var lastPosition = NO_POSITION fun dispatchScroll(recyclerView: WebtoonRecyclerView, dy: Int, centerPosition: Int) { onScroll(recyclerView, dy) if (centerPosition != NO_POSITION && centerPosition != lastPosition) { lastPosition = centerPosition onPageChanged(recyclerView, centerPosition) } } open fun onScroll(recyclerView: WebtoonRecyclerView, dy: Int) = Unit open fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) = Unit } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/fm/reader/webtoon/WebtoonScalingFrame.kt ================================================ package com.shicheeng.copymanga.fm.reader.webtoon import android.animation.ObjectAnimator import android.content.Context import android.graphics.Matrix import android.graphics.Rect import android.graphics.RectF import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent import android.view.ScaleGestureDetector import android.view.animation.AccelerateDecelerateInterpolator import android.widget.FrameLayout import android.widget.OverScroller import androidx.core.view.GestureDetectorCompat private const val MAX_SCALE = 2.5f private const val MIN_SCALE = 1f // under-scaling disabled due to buggy nested scroll class WebtoonScalingFrame @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyles: Int = 0, ) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener { private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) } private val scaleDetector = ScaleGestureDetector(context, this) private val gestureDetector = GestureDetectorCompat(context, GestureListener()) private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator()) private val transformMatrix = Matrix() private val matrixValues = FloatArray(9) private val scale get() = matrixValues[Matrix.MSCALE_X] private val transX get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X] private val transY get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y] private var halfWidth = 0f private var halfHeight = 0f private val translateBounds = RectF() private val targetHitRect = Rect() private var pendingScroll = 0 var isZoomEnable = true set(value) { field = value if (scale != 1f) { scaleChild(1f, halfWidth, halfHeight) } } init { syncMatrixValues() } override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { if (!isZoomEnable || ev == null) { return super.dispatchTouchEvent(ev) } if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) { overScroller.forceFinished(true) } gestureDetector.onTouchEvent(ev) scaleDetector.onTouchEvent(ev) // Offset event to inside the child view if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) { ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f) } // Send action cancel to avoid recycler jump when scale end if (scaleDetector.isInProgress) { ev.action = MotionEvent.ACTION_CANCEL } return super.dispatchTouchEvent(ev) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) halfWidth = measuredWidth / 2f halfHeight = measuredHeight / 2f } private fun invalidateTarget() { adjustBounds() targetChild.run { scaleX = scale scaleY = scale translationX = transX translationY = transY } val newHeight = if (scale < 1f) (height / scale).toInt() else height if (newHeight != targetChild.height) { targetChild.layoutParams.height = newHeight targetChild.requestLayout() } if (scale < 1) { targetChild.getHitRect(targetHitRect) targetChild.scrollBy(0, pendingScroll) pendingScroll = 0 } } private fun syncMatrixValues() { transformMatrix.getValues(matrixValues) } private fun adjustBounds() { syncMatrixValues() val dx = when { transX < translateBounds.left -> translateBounds.left - transX transX > translateBounds.right -> translateBounds.right - transX else -> 0f } val dy = when { transY < translateBounds.top -> translateBounds.top - transY transY > translateBounds.bottom -> translateBounds.bottom - transY else -> 0f } pendingScroll = dy.toInt() transformMatrix.postTranslate(dx, dy) syncMatrixValues() } private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) { val factor = newScale / scale if (newScale > 1) { translateBounds.set( halfWidth * (1 - newScale), halfHeight * (1 - newScale), halfWidth * (newScale - 1), halfHeight * (newScale - 1), ) } else { translateBounds.set( 0f, halfHeight - halfHeight / newScale, 0f, halfHeight - halfHeight / newScale, ) } transformMatrix.postScale(factor, factor, focusX, focusY) invalidateTarget() } override fun onScale(detector: ScaleGestureDetector): Boolean { val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) scaleChild(newScale, detector.focusX, detector.focusY) return true } override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true override fun onScaleEnd(p0: ScaleGestureDetector) { pendingScroll = 0 } private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable { override fun onScroll( e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float, ): Boolean { if (scale <= 1f) return false transformMatrix.postTranslate(-distanceX, -distanceY) invalidateTarget() return true } override fun onDoubleTap(e: MotionEvent): Boolean { val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f ObjectAnimator.ofFloat(scale, newScale).run { interpolator = AccelerateDecelerateInterpolator() duration = 300 addUpdateListener { scaleChild(it.animatedValue as Float, e.x, e.y) } start() } return true } override fun onFling( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float, ): Boolean { if (scale <= 1) return false overScroller.fling( transX.toInt(), transY.toInt(), velocityX.toInt(), velocityY.toInt(), translateBounds.left.toInt(), translateBounds.right.toInt(), translateBounds.top.toInt(), translateBounds.bottom.toInt(), ) postOnAnimation(this) return true } override fun run() { if (overScroller.computeScrollOffset()) { transformMatrix.postTranslate( overScroller.currX - transX, overScroller.currY - transY ) invalidateTarget() postOnAnimation(this) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/json/MainBannerJson.kt ================================================ package com.shicheeng.copymanga.json import com.google.gson.JsonArray import com.google.gson.JsonObject import com.shicheeng.copymanga.data.BannerList import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.parserToJson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import javax.inject.Inject import javax.inject.Singleton @Singleton class MainBannerJson @Inject constructor( private val settingPref: SettingPref, ) { private val apiHeader get() = settingPref.apiSelected private val mainPageUrl = "https://api.$apiHeader/api/v3/h5/homeIndex?platform=3&format=json" @Inject lateinit var okHttpClient: OkHttpClient suspend fun fetchMainListData(): JsonObject = withContext(Dispatchers.Default) { val request: Request = Request.Builder() .url(mainPageUrl) .build() okHttpClient.newCall(request).execute().use { response -> val jsonData = requireNotNull(response.body).string() jsonData.parserToJson().asJsonObject.getAsJsonObject("results") } } fun getBannerMain(jsonObject: JsonObject): ArrayList { val array1 = jsonObject["banners"].asJsonArray val bannerLists = ArrayList() for (ele in array1) { val element = ele.asJsonObject["type"] if (element.asInt == 1) { val list = BannerList() list.jsonObject = ele.asJsonObject bannerLists.add(list) } } return bannerLists } fun getRecMain(jsonObject: JsonObject): JsonArray = jsonObject["recComics"].asJsonObject.getAsJsonArray("list") fun getHotMain(jsonObject: JsonObject): JsonArray = jsonObject["hotComics"].asJsonArray fun getNewMain(jsonObject: JsonObject): JsonArray = jsonObject["newComics"].asJsonArray fun getFinishMain(jsonObject: JsonObject): JsonArray { return jsonObject.getAsJsonObject("finishComics").getAsJsonArray("list") } fun getRecTopic(jsonObject: JsonObject): JsonArray { return jsonObject["topics"].asJsonObject["list"].asJsonArray } fun getDayRankMain(jsonObject: JsonObject): HashMap { val map = HashMap() map[0] = jsonObject["rankDayComics"].asJsonObject.getAsJsonArray("list") map[1] = jsonObject["rankWeekComics"].asJsonObject.getAsJsonArray("list") map[2] = jsonObject["rankMonthComics"].asJsonObject.getAsJsonArray("list") return map } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/json/MangaSortJson.kt ================================================ package com.shicheeng.copymanga.json import com.shicheeng.copymanga.data.MangaSortBean enum class MangaSortJson { ORDER, THEME, PATH; companion object { @JvmStatic val order: List get() { val list: MutableList = ArrayList() val bean1 = MangaSortBean() bean1.pathName = "最久更新" bean1.pathWord = "datetime_updated" list.add(bean1) val dateUpdateNearly = MangaSortBean() dateUpdateNearly.pathName = "最近更新" dateUpdateNearly.pathWord = "-datetime_updated" list.add(dateUpdateNearly) val bean2 = MangaSortBean() bean2.pathName = "最热" bean2.pathWord = "-popular" list.add(bean2) val unpopular = MangaSortBean() unpopular.pathName = "最冷" unpopular.pathWord = "popular" list.add(unpopular) return list } @JvmStatic val topPath = listOf( MangaSortBean("无", ""), MangaSortBean("日本", "japan"), MangaSortBean("已完结", "finish"), MangaSortBean("韩国", "korea"), MangaSortBean("欧美", "west"), ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/json/UpdateMetaDataJson.kt ================================================ package com.shicheeng.copymanga.json import com.shicheeng.copymanga.BuildConfig import com.shicheeng.copymanga.data.VersionUnit import com.shicheeng.copymanga.data.versionId import com.shicheeng.copymanga.util.asArrayList import com.shicheeng.copymanga.util.await import com.shicheeng.copymanga.util.parserAsJson import com.shicheeng.copymanga.util.timeStampConvert import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import javax.inject.Inject import javax.inject.Singleton @Singleton class UpdateMetaDataJson @Inject constructor( private val okHttpClient: OkHttpClient, ) { private val updateMetadata = "https://api.github.com/repos/shizheng233/CopyMangaJava/releases?page=1&per_page=10" private val availableUpdate = MutableStateFlow(null) fun availableUpdateVersion() = availableUpdate.asStateFlow() private suspend fun getUpdateInfoVersion(): List { val request = Request.Builder().url(updateMetadata).build() val call = okHttpClient.newCall(request) val res = call.await() return buildList { mapToList(res) { add(it) } } } suspend fun fetchUpdate(): VersionUnit? = withContext(Dispatchers.Default) { runCatching { val thisVersion = versionId(BuildConfig.VERSION_NAME) val allVersion = getUpdateInfoVersion().asArrayList() allVersion.sortBy { it.versionId } allVersion.maxByOrNull { it.versionId }?.takeIf { it.versionId > thisVersion } }.onFailure { it.printStackTrace() }.onSuccess { availableUpdate.emit(it) }.getOrNull() } private inline fun mapToList( response: Response, crossinline block: (VersionUnit) -> Unit, ) { val json = response.body?.string()?.parserAsJson()?.asJsonArray ?: return for (element in json) { val singleObj = element.asJsonObject val arrest = singleObj["assets"].asJsonArray[0].asJsonObject val versionName = singleObj["tag_name"].asString val url = arrest["browser_download_url"].asString val apkSize = arrest["size"].asLong val htmlUrl = singleObj["html_url"].asString val id = singleObj["id"].asLong val time = singleObj["published_at"].asString.timeStampConvert() val description = singleObj["body"].asString val versionUnit = VersionUnit(id, htmlUrl, versionName, url, apkSize, description, time) block(versionUnit) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/modula/CopyMangaApiModula.kt ================================================ package com.shicheeng.copymanga.modula import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.ui.screen.setting.SettingPref import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object CopyMangaApiModula { @Provides @Singleton fun provideCopyMangaApi( retrofit: Retrofit, ): CopyMangaApi { return retrofit.create(CopyMangaApi::class.java) } @Provides @Singleton fun provideRetrofit( settingPref: SettingPref, okHttpClient: OkHttpClient, ): Retrofit { val headerTheKey = "https://api." val apiName = settingPref.apiSelected return Retrofit.Builder() .client(okHttpClient) .baseUrl("$headerTheKey$apiName") .addConverterFactory(MoshiConverterFactory.create()) .build() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/modula/LoginRoomModula.kt ================================================ package com.shicheeng.copymanga.modula import android.content.Context import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.shicheeng.copymanga.database.MangaLoginDatabase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module object LoginRoomModula { @Provides @Singleton fun initDatabase(@ApplicationContext context: Context): MangaLoginDatabase { return Room.databaseBuilder( context = context, klass = MangaLoginDatabase::class.java, name = "login_data" ) .addMigrations(_version1to2) .build() } @Provides @Singleton fun provideLoginDao(mangaLoginDatabase: MangaLoginDatabase) = mangaLoginDatabase.loginDao() } private val _version1to2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE LocalLoginDataModel ADD COLUMN isExpired INTEGER NOT NULL DEFAULT 0") } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/modula/OkhttpProvider.kt ================================================ package com.shicheeng.copymanga.modula import android.content.Context import coil.ImageLoader import com.shicheeng.copymanga.resposity.LoginTokenRepository import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.KeyWordSwap import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.Headers import okhttp3.OkHttpClient import javax.inject.Singleton private val queryUrlRegex = "/api/v3/comic2/.*/query".toRegex() private val comic2UrlRegex = "/api/v3/comic/.*/chapter2/.*".toRegex() @Module @InstallIn(SingletonComponent::class) object OkhttpProvider { @Singleton @Provides fun provideImageLoader( @ApplicationContext context: Context, okHttpClient: OkHttpClient, ): ImageLoader { return ImageLoader.Builder(context).okHttpClient(okHttpClient).build() } @Provides @Singleton fun headersProvide( settingPref: SettingPref, ): Headers = Headers.Builder() .add( "region", if (settingPref.useForeignApi) "1" else "0" ) .add("webp", "0") .add("platform", "1") .add("version", "2023.08.14") .add("referer", "https://www.copymanga.site/") .add(KeyWordSwap.USER_AGENT_WORD, KeyWordSwap.FAKE_USER_AGENT) .build() @Provides @Singleton fun provideOkhttp( headers: Headers, loginTokenRepository: LoginTokenRepository, ): OkHttpClient { return OkHttpClient.Builder() .addInterceptor { chain -> val oldRequest = chain.request() val token = loginTokenRepository.token // TODO 能否使用更简单的方式来进行判断 val headersNew = if ( (oldRequest.url.toUrl().path == "/api/v3/member/browse/comics" || oldRequest.url.toUrl().path == "/api/v3/member/collect/comics" || oldRequest.url.toUrl().path == "/api/v3/member/update/info" || oldRequest.url.toUrl().path.matches(queryUrlRegex) || oldRequest.url.toUrl().path.matches(comic2UrlRegex) || oldRequest.url.toUrl().path == "/api/v3/member/info" || oldRequest.url.toUrl().path == "/api/v3/member/collect/comic" || oldRequest.url.toUrl().path == "/api/v3/member/comment") && token != null && !loginTokenRepository.isExpired ) { headers.newBuilder() .add("Authorization", "Token $token") .build() } else { headers } val newRequest = oldRequest.newBuilder().headers(headersNew) chain.proceed(newRequest.build()) } .build() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/modula/RoomModula.kt ================================================ package com.shicheeng.copymanga.modula import android.content.Context import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.shicheeng.copymanga.database.MangaHistoryDataBase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object RoomModula { @Provides @Singleton fun provideHistoryDataBase(@ApplicationContext context: Context) = Room .databaseBuilder( context = context, klass = MangaHistoryDataBase::class.java, name = "manga_history_database_2" ).addMigrations( VERSION1to2, VERSION2to3, VERSION3to4, VERSION4to5, VERSION5to6 ).build() @Provides @Singleton fun provideHistoryDao(mangaHistoryDataBase: MangaHistoryDataBase) = mangaHistoryDataBase.historyDao() @Provides @Singleton fun provideKeyWordHistoryDao(mangaHistoryDataBase: MangaHistoryDataBase) = mangaHistoryDataBase.keyWordDao() } /** * 迁移新的历史记录,该历史记录保存更加详细的内容。 */ object VERSION1to2 : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { //for local information database.execSQL("ALTER TABLE manga_history_key ADD COLUMN alias TEXT NULL DEFAULT NULL") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaDetail TEXT NOT NULL DEFAULT \"空\" ") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaStatus TEXT NOT NULL DEFAULT \"空\" ") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN authorList TEXT NOT NULL DEFAULT \"空\"") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN themeList TEXT NOT NULL DEFAULT \"空\"") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaStatusId INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaRegion TEXT NOT NULL DEFAULT \"空\"") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaLastUpdate TEXT NOT NULL DEFAULT \"空\"") database.execSQL("ALTER TABLE manga_history_key ADD COLUMN mangaPopularNumber TEXT NOT NULL DEFAULT \"空\"") //for local chapter database.execSQL( "CREATE TABLE LocalChapter (" + "uuid TEXT PRIMARY KEY NOT NULL," + "groupId TEXT NULL DEFAULT NULL," + "comicId TEXT NOT NULL," + "comicPathWord TEXT NOT NULL," + "groupPathWord TEXT NOT NULL," + "name TEXT NOT NULL," + "imgType INTEGER NOT NULL," + "isReadProgress INTEGER NOT NULL," + "next TEXT NULL," + "ordered INTEGER NOT NULL," + "prev TEXT NULL," + "type INTEGER NOT NULL," + "size INTEGER NOT NULL," + "datetime_created TEXT NOT NULL," + "count INTEGER NOT NULL," + "readIndex INTEGER NOT NULL," + "news TEXT NOT NULL," + "`index` INTEGER NOT NULL" + ")" ) } } /** * 迁移到新的版本 */ object VERSION2to3 : Migration(2, 3) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE manga_history_key ADD COLUMN isSubscribe INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE LocalChapter ADD COLUMN isDownloaded INTEGER NOT NULL DEFAULT 0") } } /** * 加入搜索历史 */ object VERSION3to4 : Migration(3, 4) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "CREATE TABLE SearchHistory (" + "word TEXT PRIMARY KEY NOT NULL," + "time INTEGER NOT NULL" + ")" ) } } /** * 加入阅读完成 */ object VERSION4to5 : Migration(4, 5) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE LocalChapter ADD COLUMN isReadFinish INTEGER NOT NULL DEFAULT 0") } } object VERSION5to6 : Migration(5, 6) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE manga_history_key ADD COLUMN comicUUID TEXT NOT NULL DEFAULT \"unknown\" ") } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/modula/WorkerModula.kt ================================================ package com.shicheeng.copymanga.modula import android.content.Context import androidx.work.WorkManager import coil.ImageLoader import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object WorkerModula { @Singleton @Provides fun provideWorkerManager( @ApplicationContext context: Context, ): WorkManager { return WorkManager.getInstance(context) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/AuthorsMangaPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.authormanga.AuthorMangaItem import com.shicheeng.copymanga.domin.CopyMangaApi import kotlin.coroutines.Continuation class AuthorsMangaPagingSource( private val pathWord: String, private val copyMangaApi: CopyMangaApi, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { val data = copyMangaApi.comicAuthors(author = pathWord, offset = offset) LoadResult.Page( data = data.results.list, nextKey = if (data.results.offset > data.results.total) null else offset + 21, prevKey = null ) } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/ComicCommentPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.mangacomment.MangaCommentListItem import com.shicheeng.copymanga.domin.CopyMangaApi class ComicCommentPagingSource( private val uuid: String, private val copyMangaApi: CopyMangaApi, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { val model = copyMangaApi.comicComments(comicID = uuid, offset = offset) LoadResult.Page( data = model.results.list, prevKey = null, nextKey = if (model.results.offset > model.results.total) null else offset + 20 ) } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/ExplorePagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.domin.CopyMangaApi class ExplorePagingSource( private val copyMangaApi: CopyMangaApi, private val order: String?, private val themeWord: String?, private val top: String?, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val offset = params.key ?: 0 val data = copyMangaApi.fetchMangaFilter( offset = offset, ordering = order, theme = themeWord, top = top ) if (offset <= data.results.total) { LoadResult.Page(data.results.list, null, offset + 21) } else { LoadResult.Page(data.results.list, null, null) } } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/FinishedPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.json.MangaSortJson class FinishedPagingSource( private val copyMangaApi: CopyMangaApi, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val offset = params.key ?: 0 val data = copyMangaApi.fetchMangaFilter( offset = offset, top = MangaSortJson.topPath.find { x -> x.pathName == "已完结" }?.pathWord ) if (offset <= data.results.total) { LoadResult.Page(data.results.list, null, offset + 21) } else { LoadResult.Page(data.results.list, null, null) } } catch (e: Exception) { LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/HotPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.resposity.MangaHotRepository class HotPagingSource( private val repository: MangaHotRepository, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val offset = params.key ?: 0 val data = repository.fetchHotMangas(offset) if (offset <= data.results.total) { LoadResult.Page(data.results.list, null, offset + 21) } else { LoadResult.Page(data.results.list, null, null) } } catch (e: Exception) { LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/MangaTopicListPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.topicalllist.TopicAllListItem import com.shicheeng.copymanga.domin.CopyMangaApi class MangaTopicListPagingSource( private val copyMangaApi: CopyMangaApi, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { val data = copyMangaApi.fetchAllTopicListItem(offset = offset) LoadResult.Page( data = data.results.list, prevKey = null, nextKey = if (data.results.offset >= data.results.total) null else offset + 21 ) } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/NewestPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.newsest.MangaBlock import com.shicheeng.copymanga.resposity.MangaNewestRepository class NewestPagingSource( private val repository: MangaNewestRepository, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val offset = params.key ?: 0 val data = repository.fetchNewestMangas(offset) if (offset <= data.results.total) { LoadResult.Page(data.results.list, null, offset + 21) } else { LoadResult.Page(data.results.list, null, null) } } catch (e: Exception) { LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/RankPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.rank.Item import com.shicheeng.copymanga.domin.CopyMangaApi class RankPagingSource( private val copyMangaApi: CopyMangaApi, private val rankType: String, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val nextPageKey = params.key ?: 0 val ranks = copyMangaApi.getRank(offset = nextPageKey, dateType = rankType) if (nextPageKey <= ranks.results.total) { LoadResult.Page(ranks.results.list, prevKey = null, nextKey = nextPageKey + 21) } else { LoadResult.Page(emptyList(), prevKey = null, nextKey = null) } } catch (e: Exception) { LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/RecommendPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.recommend.RecommendDataModel import com.shicheeng.copymanga.resposity.MangaRecommendRepository class RecommendPagingSource( private val recommendRepository: MangaRecommendRepository, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val nextPageNumber = params.key ?: 0 val responseData = recommendRepository.fetchRecommendMangas(nextPageNumber) if (nextPageNumber <= responseData.results.total) { LoadResult.Page( data = responseData.results.list, prevKey = null, nextKey = nextPageNumber + 21 ) } else { LoadResult.Page(data = emptyList(), prevKey = null, nextKey = null) } } catch (e: Exception) { LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/SearchResultPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.search.SearchResultDataModel import com.shicheeng.copymanga.domin.CopyMangaApi class SearchResultPagingSource( private val word: String, private val copyMangaApi: CopyMangaApi, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val nextPageNumber = params.key ?: 0 val responseData = copyMangaApi.search(q = word, offset = nextPageNumber) if (nextPageNumber <= responseData.results.total) { LoadResult.Page( data = responseData.results.list, prevKey = null, nextKey = nextPageNumber + 21 ) } else { LoadResult.Page(data = responseData.results.list, prevKey = null, nextKey = null) } } catch (e: Exception) { LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/TopicDetailListPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.topiclist.TopicItem import com.shicheeng.copymanga.domin.CopyMangaApi class TopicDetailListPagingSource( private val copyMangaApi: CopyMangaApi, private val pathWord: String, private val type: Int, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { val data = copyMangaApi.getMangaTopicList( offset = offset, type = type, name = pathWord ) LoadResult.Page( data = data.results.list, prevKey = null, nextKey = if (data.results.offset >= data.results.total) null else offset + 21 ) } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/WebHistoryPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.webhistory.WebHistoryItem import com.shicheeng.copymanga.domin.CopyMangaApi class WebHistoryPagingSource(private val copyMangaApi: CopyMangaApi) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { val data = copyMangaApi.browsedComics(offset = offset) return LoadResult.Page( data = data.results.list, prevKey = null, nextKey = if (data.results.offset > data.results.total) null else offset + 21 ) } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/pagingsource/WebShelfPagingSource.kt ================================================ package com.shicheeng.copymanga.pagingsource import androidx.paging.PagingSource import androidx.paging.PagingState import com.shicheeng.copymanga.data.webbookshelf.WebBookshelfItem import com.shicheeng.copymanga.domin.CopyMangaApi class WebShelfPagingSource( private val copyMangaApi: CopyMangaApi, ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { val offset = params.key ?: 0 return try { val data = copyMangaApi.bookshelfWeb(offset = offset) LoadResult.Page( data = data.results.list, prevKey = null, nextKey = if (data.results.offset > data.results.total) null else offset + 21 ) } catch (e: Exception) { e.printStackTrace() LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/AuthorsMangaRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.shicheeng.copymanga.data.authormanga.AuthorMangaItem import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.AuthorsMangaPagingSource import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @Singleton class AuthorsMangaRepository @Inject constructor( private val copyMangaApi: CopyMangaApi ) { fun fetchMangaByPathWord(pathWord:String): Flow> { return Pager( config = PagingConfig(pageSize = 1) ){ AuthorsMangaPagingSource(pathWord, copyMangaApi) }.flow } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/ComicCommentRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.ComicCommentPagingSource import com.shicheeng.copymanga.util.SendUIState import kotlinx.coroutines.flow.flow import javax.inject.Inject import javax.inject.Singleton @Singleton class ComicCommentRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun loadComment(uuid: String) = Pager( config = PagingConfig(1) ) { ComicCommentPagingSource(uuid, copyMangaApi) }.flow fun push(comic: String, comment: String) = flow { emit(SendUIState.Loading) try { val data = copyMangaApi.commentPush(comic, comment) emit(SendUIState.Success(data)) } catch (e: Exception) { e.printStackTrace() emit(SendUIState.Error(e)) } finally { kotlinx.coroutines.delay(3000) emit(SendUIState.Idle) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/LoginDetailRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.data.logininfoshort.LoginInfoShortDataModel import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.util.UIState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject import javax.inject.Singleton @Singleton class LoginDetailRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun detail(): Flow> = flow { emit(UIState.Loading) try { val data = copyMangaApi.shortInfo() emit(UIState.Success(data)) } catch (e: Exception) { emit(UIState.Error(e)) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/LoginRepository.kt ================================================ package com.shicheeng.copymanga.resposity import android.util.Base64 import com.shicheeng.copymanga.dao.MangaLoginDao import com.shicheeng.copymanga.data.login.LocalLoginDataModel import com.shicheeng.copymanga.data.login.toLoginDataModel import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.LoginState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import java.nio.charset.Charset import javax.inject.Inject import javax.inject.Singleton @Singleton class LoginRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, private val loginDao: MangaLoginDao, private val settingPref: SettingPref, ) { suspend fun login(username: String, password: String) = flow { emit(LoginState.Loading) val salt = (0..Int.MAX_VALUE).random() val passwordEncode = "$password-${salt}".toByteArray(Charset.forName("utf-8")) val passwordB64 = Base64.encodeToString(passwordEncode, Base64.DEFAULT) try { val loginDataModel = copyMangaApi.login(username, passwordB64 = passwordB64, salt = salt) val loginLocal = loginDataModel.toLoginDataModel(isSelected = true) loginDao.updateOrInsertLoginData(loginLocal) val newList = loginDao .getLoginDataAsync() .map { x -> x.copy(selected = x.userID == loginLocal.userID) } settingPref.selectedUUId(uuid = loginLocal.userID) loginDao.updateOrInsertLoginData(localLoginDataModels = newList.toTypedArray()) emit(LoginState.Success(loginDataModel)) } catch (e: Exception) { e.printStackTrace() emit(LoginState.Error(e)) } } fun getAllLoginInstance() = loginDao.getLoginData() fun getUserByUUid(uuid: String) = loginDao.getLoginDataByUserId(uuid) fun deleteOneInstance(localLoginDataModel: LocalLoginDataModel) = loginDao::deleteLoginData suspend fun selectOne( uuid: String, ) = withContext(Dispatchers.IO) { val newList = loginDao.getLoginDataAsync().map { x -> x.copy(selected = x.userID == uuid) } loginDao.updateOrInsertLoginData(localLoginDataModels = newList.toTypedArray()) settingPref.selectedUUId(uuid) } fun testLoginStatus( uuid: String? = settingPref.loginPerson, ) = flow { val prevLoginInfo = loginDao.getLoginDataByUserIdSafety(uuid) if (prevLoginInfo == null) { emit(null) return@flow } try { val info = copyMangaApi.loginInfo() .toLoginDataModel( localLoginDataModel = prevLoginInfo, isSelected = prevLoginInfo.selected, isExpired = false ) loginDao.updateOrInsertLoginData(info) emit(null) } catch (e: Exception) { val info = prevLoginInfo.copy(isExpired = true) loginDao.updateOrInsertLoginData(info) emit(e) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/LoginTokenRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.dao.MangaLoginDao import com.shicheeng.copymanga.ui.screen.setting.SettingPref import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import javax.inject.Inject import javax.inject.Singleton /** * 防止依赖注入循环。 */ @Singleton class LoginTokenRepository @Inject constructor( private val loginDao: MangaLoginDao, private val settingPref: SettingPref, ) { /** * 登录Token,没有则返回null。 */ val token: String? get() { return loginDao.getCurrentToken(settingPref.loginPerson ?: return null) } val isExpired: Boolean get() { return loginDao.isExpired(settingPref.loginPerson ?: return true) } val isExpiredFlow:Flow get() { return loginDao.isExpiredFlow(settingPref.loginPerson ?: return emptyFlow()) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaFilterRepository.kt ================================================ package com.shicheeng.copymanga.resposity import android.util.Log import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.ExplorePagingSource import com.shicheeng.copymanga.util.await import com.shicheeng.copymanga.util.parserAsJson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import retrofit2.Retrofit import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaFilterRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, private val okHttpClient: OkHttpClient, private val retrofit: Retrofit, ) { /** * 需要自己手动解析 */ suspend fun theme(): List = withContext(Dispatchers.Default) { val request = Request.Builder() .url("https://" + retrofit.baseUrl().host + "/api/v3/h5/filterIndex/comic/tags") .build() val call = okHttpClient.newCall(request).await() call.body?.string()?.let { buildList { add(MangaSortBean("无", "")) it.parserAsJson().asJsonObject["results"].asJsonObject["theme"].asJsonArray.forEach { val name = it.asJsonObject["name"].asString val pathWord = it.asJsonObject["path_word"].asString add(MangaSortBean(name, pathWord)) } } } ?: emptyList() } fun filterMangas( top: String? = null, theme: String? = null, ordering: String? = null, ): Flow> { return Pager( config = PagingConfig(pageSize = 21), pagingSourceFactory = { ExplorePagingSource(copyMangaApi, ordering, theme, top) } ).flow } } fun Any.logD(tag: String = "com.shihcheeng.logd") { Log.d(tag, "$this") } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaFinishedRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.json.MangaSortJson import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaFinishedRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { suspend fun fetchFinishManga(offset: Int) = copyMangaApi.fetchMangaFilter( top = MangaSortJson.topPath.find { x -> x.pathName == "已完结" }?.pathWord, offset = offset ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaHistoryRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.dao.MangeLocalHistoryDao import com.shicheeng.copymanga.dao.SearchHistoryDao import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import com.shicheeng.copymanga.data.searchhistory.SearchHistory import com.shicheeng.copymanga.util.processLifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaHistoryRepository @Inject constructor( private val mangeLocalHistoryDao: MangeLocalHistoryDao, private val searchedWordDao: SearchHistoryDao, ) { val allHistoryDao: Flow> = mangeLocalHistoryDao.getAllHistory() suspend fun totalHistoryManga() = mangeLocalHistoryDao.fetchTotalManga() suspend fun getMangaByPathWord(pathWord: String): LocalSavableMangaModel? { return mangeLocalHistoryDao.getMangaByPathWord(pathWord) } suspend fun fetchMangaChapterByPathWord(pathWord: String): List? { return mangeLocalHistoryDao.fetchMangaChaptersByPathWord(pathWord) } fun fetchMangaChapterByPathWordFlow(pathWord: String): Flow?> { return mangeLocalHistoryDao.fetchMangaChaptersByPathWordFlow(pathWord) } fun fetchMangaByPathWordInFlow(pathWord: String) = mangeLocalHistoryDao.fetchHistoryByPathWordInFlow(pathWord) suspend fun update(mangaLocalHistory: MangaHistoryDataModel) { mangeLocalHistoryDao.updateLocal(mangaLocalHistory) } /** * 保存漫画历史。其生命周期不随ViewModel。 */ fun updateAsync(mangaLocalHistory: MangaHistoryDataModel) { processLifecycleScope.launch(Dispatchers.IO) { try { mangeLocalHistoryDao.updateLocal(mangaLocalHistory) } catch (e: Exception) { e.printStackTrace() } } } suspend fun updateLocalChapter(localChapter: LocalChapter) { mangeLocalHistoryDao.addLocalChapter(localChapter) } suspend fun updateLocalChapter(localChapter: List) { mangeLocalHistoryDao.addLocalChapter(*localChapter.toTypedArray()) } suspend fun getHistoryByMangaPathWord(pathWord: String): MangaHistoryDataModel? = mangeLocalHistoryDao.getHistoryForInfoByPathWord(pathWord) suspend fun delHistory() { mangeLocalHistoryDao.deleteAllHistory() } suspend fun deleteSingleHistory(mangaHistoryDataModel: MangaHistoryDataModel) { mangeLocalHistoryDao.deleteSingle(mangaHistoryDataModel) } fun historySearchedWord() = searchedWordDao.loadWordHistory() suspend fun delKeyWordHistory(searchHistory: SearchHistory) = searchedWordDao.detectSearchedWordHistory(searchHistory) suspend fun upsertSearchWord(searchHistory: SearchHistory) { searchedWordDao.upsertWord(searchHistory) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaHotRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.json.MangaSortJson import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaHotRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { suspend fun fetchHotMangas(offset: Int) = copyMangaApi.fetchMangaFilter( ordering = MangaSortJson.order.find { x -> x.pathName == "最热" }?.pathWord, offset = offset ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaInfoRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.data.chapter.toLocalChapter import com.shicheeng.copymanga.data.info.MangaInfoDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.domin.DownloadFileDetectUtil import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.formNumberToRead import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaInfoRepository @Inject constructor( private val detectUtil: DownloadFileDetectUtil, private val copyMangaApi: CopyMangaApi, private val mangaHistoryRepository: MangaHistoryRepository, private val settingPref: SettingPref, ) { suspend fun fetchMangaChapters(pathWord: String): List { return mangaHistoryRepository.fetchMangaChapterByPathWord(pathWord) ?.takeIf { it.isNotEmpty() } ?: copyMangaApi.fetchChapters(pathWord = pathWord).let { it.results.list.map { remoteChapter -> remoteChapter.toLocalChapter( readIndex = 0, isReadInProgress = false, isDownloaded = detectUtil.detectChapterDownloadedByUUID( pathWord, remoteChapter.uuid ), isReadFinish = false ) } }.also { mangaHistoryRepository.updateLocalChapter(it) } } suspend fun fetchMangaChaptersForce(pathWord: String): List { val mangaLocalChapters = mangaHistoryRepository.fetchMangaChapterByPathWord(pathWord) return copyMangaApi.fetchChapters(pathWord = pathWord).let { it.results.list.map { remoteChapter -> remoteChapter.toLocalChapter( readIndex = mangaLocalChapters?.find { x -> x.uuid == remoteChapter.uuid }?.readIndex ?: 0, isReadInProgress = mangaLocalChapters?.find { x -> x.uuid == remoteChapter.uuid }?.isReadProgress ?: false, isDownloaded = detectUtil.detectChapterDownloadedByUUID( pathWord, remoteChapter.uuid ), isReadFinish = mangaLocalChapters?.find { x -> x.uuid == remoteChapter.uuid }?.isReadFinish ?: false ) } }.also { mangaHistoryRepository.updateLocalChapter(it) } } suspend fun collect(comicId: String, isCollect: Boolean): Boolean { return try { copyMangaApi.comicCollect(comicId, isCollect = if (isCollect) 1 else 0) true } catch (e: Exception) { e.printStackTrace() false } } suspend fun fetchMangaInfo(pathWord: String): MangaHistoryDataModel { return mangaHistoryRepository.getHistoryByMangaPathWord(pathWord) ?: copyMangaApi.getMangaInfo(pathWord = pathWord).toMangaLocalInfo( readerMode = ReaderMode.valueOf(settingPref.readerMode) ).also { mangaHistoryRepository.update(it) } } suspend fun fetchMangaInfoForce(pathWord: String): MangaHistoryDataModel { val oldHistory = mangaHistoryRepository.getHistoryByMangaPathWord(pathWord) return copyMangaApi.getMangaInfo(pathWord = pathWord).toMangaLocalInfo( readerMode = ReaderMode.idOf(oldHistory?.readerModeId) ?: ReaderMode.valueOf(settingPref.readerMode), isSubscribe = oldHistory?.isSubscribe ?: false ).also { mangaHistoryRepository.update(it) } } suspend fun fetchContent( pathWord: String, uuid: String, ): List { val url = copyMangaApi.fetchMangaContentPicture(pathWord, uuid).results.chapter return buildList { url.contents.forEachIndexed { index, c -> add( MangaReaderPage( url = c.url, index = url.words[index], uuid = url.uuid ) ) } }.sortedBy { it.index } } fun fetchComicWebHistory(pathWord: String) = flow { val dataModel = copyMangaApi.comicWebHistory(pathWord) emit(dataModel) } suspend fun fetchContentMayLocal( localList: List? = null, pathWord: String, uuid: String, ): List = withContext(Dispatchers.Default) { if (localList != null) { val sortedList = localList.sortedWith { text1, text2 -> text1.url .split("/") .last() .split("_") .last() .split(".") .first() .toInt() .compareTo( text2.url .split("/") .last() .split("_") .last() .split(".") .first() .toInt() ) } val newList = buildList { for (i in sortedList.indices) { add(sortedList[i].copy(index = i)) } } newList } else { try { val url = copyMangaApi.fetchMangaContentPicture(pathWord, uuid).results.chapter buildList { url.contents.forEachIndexed { index, c -> add( MangaReaderPage( url = c.url, index = url.words[index], uuid = url.uuid ) ) } }.sortedBy { it.index } } catch (e: Exception) { e.printStackTrace() emptyList() } } } } fun MangaInfoDataModel.toMangaLocalInfo( readerMode: ReaderMode, isSubscribe: Boolean = false, ): MangaHistoryDataModel { return MangaHistoryDataModel( name = results.comic.name, time = System.currentTimeMillis(), url = results.comic.cover, pathWord = results.comic.pathWord, nameChapter = results.comic.lastChapter.name, positionChapter = 0, positionPage = 0, readerModeId = readerMode.id, mangaDetail = results.comic.brief, mangaLastUpdate = results.comic.datetimeUpdated ?: "未知", mangaPopularNumber = results.popular.toLong().formNumberToRead(), mangaRegion = results.comic.region.display, mangaStatus = results.comic.status.display, mangaStatusId = results.comic.status.value, themeList = results.comic.theme.map { MangaSortBean(it.name, it.pathWord) }, authorList = results.comic.author, alias = results.comic.alias, isSubscribe = isSubscribe, comicUUID = results.comic.uuid ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaMainPageRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.google.gson.JsonArray import com.google.gson.JsonObject import com.shicheeng.copymanga.data.DataBannerBean import com.shicheeng.copymanga.data.ListBeanManga import com.shicheeng.copymanga.data.MainPageDataModel import com.shicheeng.copymanga.data.MainTopicDataModel import com.shicheeng.copymanga.data.MangaRankMiniModel import com.shicheeng.copymanga.json.MainBannerJson import com.shicheeng.copymanga.util.authorNameReformation import com.shicheeng.copymanga.util.formNumberToRead import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaMainPageRepository @Inject constructor( private val mainBannerJson: MainBannerJson, ) { private fun fetchMainBannerData(inputJsonObject: JsonObject): List { val data = mainBannerJson.getBannerMain(inputJsonObject) return buildList { data.forEach { val bannerBean = DataBannerBean() val jsonObject1 = it.jsonObject //Banner组下面的各个jsonObject bannerBean.bannerBrief = jsonObject1["brief"].asString bannerBean.bannerImageUrl = jsonObject1["cover"].asString bannerBean.uuidManga = jsonObject1["comic"] .asJsonObject["path_word"].asString add(bannerBean) } } } private fun fetchMainRowData(inputJsonArray: JsonArray): List { return buildList { inputJsonArray.forEach { jsonElement -> //因为有第二个jsonObject,所以需要再次获取一次。 val jsonObject1 = jsonElement.asJsonObject.getAsJsonObject("comic") val nameManga = jsonObject1["name"].asString val urlCoverManga = jsonObject1["cover"].asString val pathWordManga = jsonObject1["path_word"].asString val mangaAuthor = jsonObject1["author"].asJsonArray.takeIf { it.size() != 0 } ?.authorNameReformation() ?: "未知" val beanManga = ListBeanManga( nameManga = nameManga, authorManga = mangaAuthor, urlCoverManga = urlCoverManga, pathWordManga = pathWordManga ) add(beanManga) } } } private fun fetchMainRowData2(inputJsonArray: JsonArray): List { return buildList { inputJsonArray.forEach { jsonElement -> val jsonObject1 = jsonElement.asJsonObject val nameManga = jsonObject1["name"].asString val urlCoverManga = jsonObject1["cover"].asString val pathWordManga = jsonObject1["path_word"].asString val mangaAuthorList = jsonObject1["author"].asJsonArray.authorNameReformation() val beanManga = ListBeanManga(nameManga, mangaAuthorList, urlCoverManga, pathWordManga) add(beanManga) } } } private fun transformMainRecTopic(inputJsonArray: JsonArray): List { return inputJsonArray.map { element -> element.asJsonObject.let { val title = it["title"].asString val journal = it["journal"].asString val coverUrl = it["cover"].asString val period = it["period"].asString val type = it["type"].asInt val brief = it["brief"].asString val pathWord = it["path_word"].asString val time = it["datetime_created"].asString MainTopicDataModel( name = title, journal = journal, coverUrl = coverUrl, period = period, type = type, brief = brief, pathWord = pathWord, datetimeCreated = time ) } } } private fun parserJsonLeaderBoardData(array: JsonArray?): List { return buildList { array?.forEach { jsonElement -> val comic = jsonElement.asJsonObject["comic"].asJsonObject val popular = comic["popular"].asLong.formNumberToRead() val name = comic["name"].asString val pathWord = comic["path_word"].asString val author = comic["author"].asJsonArray.authorNameReformation() val cover = comic["cover"].asString val riseNum = jsonElement.asJsonObject["rise_num"].asLong.formNumberToRead() val data = MangaRankMiniModel(name, author, cover, popular, riseNum, pathWord) add(data) } } } /** * 主页数据 */ suspend fun fetchMainData() = withContext(Dispatchers.Default) { val mainData = mainBannerJson.fetchMainListData() val listBanner = fetchMainBannerData(mainData) val listRecommend = fetchMainRowData(mainBannerJson.getRecMain(mainData)) val listNewest = fetchMainRowData(mainBannerJson.getNewMain(mainData)) val listHot = fetchMainRowData(mainBannerJson.getHotMain(mainData)) val listFinished = fetchMainRowData2(mainBannerJson.getFinishMain(mainData)) val mapRankJsonArray = mainBannerJson.getDayRankMain(mainData) val listRankWeek = parserJsonLeaderBoardData(mapRankJsonArray[1]) val listRankDay = parserJsonLeaderBoardData(mapRankJsonArray[0]) val listRankMonth = parserJsonLeaderBoardData(mapRankJsonArray[2]) val topic = transformMainRecTopic(mainBannerJson.getRecTopic(mainData)) MainPageDataModel( listBanner = listBanner, listRecommend = listRecommend, listRankDay = listRankDay, listRankWeek = listRankWeek, listRankMonth = listRankMonth, listHot = listHot, listNewest = listNewest, listFinished = listFinished, topicList = topic ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaNewestRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.domin.CopyMangaApi import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaNewestRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { suspend fun fetchNewestMangas(offset: Int) = copyMangaApi.getMangaNewest(offset = offset) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaRankRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.shicheeng.copymanga.data.rank.Item import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.RankPagingSource import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaRankRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun fetchMangaRank(type: String): Flow> { return Pager( config = PagingConfig(pageSize = 21), pagingSourceFactory = { RankPagingSource(copyMangaApi, type) } ).flow } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaRecommendRepository.kt ================================================ package com.shicheeng.copymanga.resposity import com.shicheeng.copymanga.domin.CopyMangaApi import javax.inject.Inject class MangaRecommendRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { suspend fun fetchRecommendMangas(offset: Int) = copyMangaApi.getMangaRecommend(offset = offset) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaSearchRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.shicheeng.copymanga.data.search.SearchResultDataModel import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.SearchResultPagingSource import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaSearchRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun fetchSearchResult(query: String): Flow> { return Pager( config = PagingConfig(pageSize = 21), pagingSourceFactory = { SearchResultPagingSource(query, copyMangaApi) } ).flow } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/MangaTopicDetailRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.TopicDetailListPagingSource import com.shicheeng.copymanga.util.UIState import kotlinx.coroutines.flow.flow import javax.inject.Inject import javax.inject.Singleton @Singleton class MangaTopicDetailRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun load(pathWord: String) = flow { emit(UIState.Loading) try { val data = copyMangaApi.getMangaTopicInfo(pathWord) emit(UIState.Success(data)) } catch (e: Exception) { emit(UIState.Error(e)) } } fun mangas( pathWord: String, type: Int, ) = Pager( config = PagingConfig(pageSize = 1) ) { TopicDetailListPagingSource(copyMangaApi, pathWord, type) }.flow } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/WebHistoryRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.WebHistoryPagingSource import javax.inject.Inject import javax.inject.Singleton @Singleton class WebHistoryRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun historyOnWeb() = Pager( config = PagingConfig(pageSize = 1) ) { WebHistoryPagingSource(copyMangaApi) }.flow } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/resposity/WebShelfRepository.kt ================================================ package com.shicheeng.copymanga.resposity import androidx.paging.Pager import androidx.paging.PagingConfig import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.WebShelfPagingSource import javax.inject.Inject import javax.inject.Singleton @Singleton class WebShelfRepository @Inject constructor( private val copyMangaApi: CopyMangaApi, ) { fun loadWebShelf() = Pager( pagingSourceFactory = { WebShelfPagingSource(copyMangaApi) }, config = PagingConfig(pageSize = 1) ).flow } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/DownloadState.kt ================================================ package com.shicheeng.copymanga.server import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.local.LocalSavableMangaModel sealed interface DownloadStateChapter { val chapterID: Int val chapter: LocalSavableMangaModel class WAITING( override val chapterID: Int, override val chapter: LocalSavableMangaModel, ) : DownloadStateChapter class PREPARE( override val chapterID: Int, override val chapter: LocalSavableMangaModel, ) : DownloadStateChapter class DOWNLOADING( override val chapterID: Int, override val chapter: LocalSavableMangaModel, totalChapters: Int, currentChapter: Int, val totalPages: Int, val currentPage: Int, val currentLocalChapter: LocalChapter, ) : DownloadStateChapter { val max: Int = totalChapters * totalPages val progress: Int = totalPages * currentChapter + currentPage + 1 val percent: Float = progress.toFloat() / max } class ERROR( override val chapterID: Int, override val chapter: LocalSavableMangaModel, val error: Throwable, ) : DownloadStateChapter class DONE( override val chapterID: Int, override val chapter: LocalSavableMangaModel, ) : DownloadStateChapter class PostBeforeDone( override val chapterID: Int, override val chapter: LocalSavableMangaModel, ) : DownloadStateChapter class CANCEL(override val chapterID: Int, override val chapter: LocalSavableMangaModel) : DownloadStateChapter } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/DownloadState.kt ================================================ package com.shicheeng.copymanga.server.download.domin import androidx.work.Data import com.shicheeng.copymanga.data.LocalManga import com.shicheeng.copymanga.data.local.LocalSavableMangaModel data class DownloadState( val localSavableMangaModel: LocalSavableMangaModel, val error: String? = null, val isStopped: Boolean = false, val isPaused: Boolean = false, val totalChapters: Int = 0, val currentChapter: Int = 0, val isIndeterminate: Boolean = false, val totalPages: Int = 0, val currentPage: Int = 0, val localManga: LocalManga? = null, val downloadedChapters: Array = emptyArray(), val timestamp: Long = System.currentTimeMillis(), val eta: Long = -1L, ) { val max: Int = totalChapters * totalPages val progress: Int = totalPages * currentChapter + currentPage + 1 val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE val isParticularProgress: Boolean get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate val isFinalState: Boolean get() = localManga != null || (error != null && !isPaused) fun transformToWorkData() = Data.Builder() .putInt(MANGA_MAX, max) .putInt(MANGA_PROGRESS, progress) .putString(MANGA_ERROR, error) .putStringArray(MANGA_DOWNLOAD_CHAPTER, downloadedChapters) .putLong(MANGA_TIME_STAMP, timestamp) .putString(MANGA_PATH_WORD, localSavableMangaModel.mangaHistoryDataModel.pathWord) .putBoolean(IS_INDETERMINATE, isIndeterminate) .putBoolean(IS_STOPPED, isStopped) .putBoolean(IS_PAUSE, isPaused) .putLong(MANGA_ETA, eta) .build() override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as DownloadState if (localSavableMangaModel != other.localSavableMangaModel) return false if (error != other.error) return false if (totalChapters != other.totalChapters) return false if (currentChapter != other.currentChapter) return false if (totalPages != other.totalPages) return false if (currentPage != other.currentPage) return false if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false if (timestamp != other.timestamp) return false if (isStopped != other.isStopped) return false if (max != other.max) return false if (progress != other.progress) return false if (isPaused != other.isPaused) return false if (isIndeterminate != other.isIndeterminate) return false return percent == other.percent } override fun hashCode(): Int { var result = localSavableMangaModel.hashCode() result = 31 * result + (error?.hashCode() ?: 0) result = 31 * result + totalChapters result = 31 * result + currentChapter result = 31 * result + totalPages result = 31 * result + currentPage result = 31 * result + downloadedChapters.contentHashCode() result = 31 * result + timestamp.hashCode() result = 31 * result + max result = 31 * result + totalPages result = 31 * result + percent.hashCode() result = 31 * result + isIndeterminate.hashCode() result = 31 * result + isPaused.hashCode() result = 31 * result + isStopped.hashCode() return result } companion object { private const val PROGRESS_NONE = -1f private const val MANGA_PATH_WORD = "MangaPathWord" private const val MANGA_MAX = "MangaMax" private const val MANGA_ETA = "MangaEta" private const val IS_PAUSE = "IsPause" private const val IS_STOPPED = "IsStopped" private const val MANGA_ERROR = "MangaError" private const val MANGA_DOWNLOAD_CHAPTER = "MangaDownloadChapter" private const val MANGA_CURRENT = "MangaCurrent" private const val IS_INDETERMINATE = "IsIndeterminate" private const val MANGA_TIME_STAMP = "MangaTimeStamp" private const val MANGA_PROGRESS = "MangaProgress" infix fun getMangaPathWord(data: Data) = data.getString(MANGA_PATH_WORD) infix fun getError(data: Data) = data.getString(MANGA_ERROR) infix fun getMax(data: Data) = data.getInt(MANGA_MAX, 0) infix fun getProgress(data: Data) = data.getInt(MANGA_PROGRESS, 0) infix fun timeStampWhich(data: Data) = data.getLong(MANGA_TIME_STAMP, 0L) infix fun downloadChaptersIn(data: Data): Array = data.getStringArray(MANGA_DOWNLOAD_CHAPTER) ?: emptyArray() infix fun indeterminateFor(data: Data): Boolean { return data.getBoolean(IS_INDETERMINATE, false) } infix fun isPauseIn(data: Data): Boolean = data.getBoolean(IS_PAUSE, false) infix fun isStoppedIn(data: Data): Boolean = data.getBoolean(IS_STOPPED, false) infix fun timeETAIn(data: Data) = data.getLong(MANGA_ETA, 0L) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/DownloaderLocalIndex.kt ================================================ package com.shicheeng.copymanga.server.download.domin import com.google.gson.JsonObject import com.google.gson.JsonParser import com.shicheeng.copymanga.BuildConfig import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.data.info.Author import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import com.shicheeng.copymanga.util.add import com.shicheeng.copymanga.util.toJsonArray class DownloaderLocalIndex(source: String?) { constructor(source: () -> String?) : this(source()) private val jsonObject = if ( !source.isNullOrEmpty() && source.isNotEmpty() && JsonParser.parseString(source).isJsonObject ) { JsonParser.parseString(source).asJsonObject } else { JsonObject() } fun setMangaData(localSavableMangaModel: LocalSavableMangaModel, append: Boolean) { jsonObject.apply { addProperty("comic_id", localSavableMangaModel.mangaHistoryDataModel.comicUUID) addProperty("name", localSavableMangaModel.mangaHistoryDataModel.name) addProperty("cover", localSavableMangaModel.mangaHistoryDataModel.url) addProperty("description", localSavableMangaModel.mangaHistoryDataModel.mangaDetail) addProperty("state", localSavableMangaModel.mangaHistoryDataModel.mangaStatus) addProperty("alias", localSavableMangaModel.mangaHistoryDataModel.alias) addProperty("last_update", localSavableMangaModel.mangaHistoryDataModel.mangaLastUpdate) addProperty("name_chapter", localSavableMangaModel.mangaHistoryDataModel.nameChapter) addProperty("path_word", localSavableMangaModel.mangaHistoryDataModel.pathWord) addProperty( "manga_popular_num", localSavableMangaModel.mangaHistoryDataModel.mangaPopularNumber ) addProperty("manga_region", localSavableMangaModel.mangaHistoryDataModel.mangaRegion) addProperty( "manga_reader_int", localSavableMangaModel.mangaHistoryDataModel.readerModeId ) addProperty( "manga_state_id", localSavableMangaModel.mangaHistoryDataModel.mangaStatusId ) addProperty( "time", localSavableMangaModel.mangaHistoryDataModel.time ) add("tags") { localSavableMangaModel.mangaHistoryDataModel.themeList.toJsonArray( header = { x -> x.pathName }, values = { y -> y.pathWord }, headerProperty = "name", valuesProperty = "path_word" ) } add("authors") { localSavableMangaModel.mangaHistoryDataModel.authorList.toJsonArray( header = { x -> x.name }, headerProperty = "name", valuesProperty = "path_word", values = { y -> y.pathWord } ) } if (!append || !jsonObject.has("chapters")) { add("chapters", JsonObject()) } addProperty("app_version", BuildConfig.VERSION_NAME) addProperty("app_id", BuildConfig.APPLICATION_ID) } } fun getMangaData() = if (jsonObject.isEmpty) null else runCatching { MangaHistoryDataModel( name = jsonObject["name"].asString, comicUUID = jsonObject["comic_id"].asString, readerModeId = jsonObject["manga_reader_int"].asInt, mangaLastUpdate = jsonObject["last_update"].asString, mangaDetail = jsonObject["description"].asString, nameChapter = jsonObject["name_chapter"].asString, time = jsonObject["time"].asLong, alias = jsonObject["alias"].asString.takeIf { it.isNotBlank() && it.isNotEmpty() }, pathWord = jsonObject["path_word"].asString, mangaPopularNumber = jsonObject["manga_popular_num"].asString, mangaRegion = jsonObject["manga_region"].asString, mangaStatus = jsonObject["state"].asString, mangaStatusId = jsonObject["manga_state_id"].asInt, themeList = jsonObject["tags"].asJsonArray.map { MangaSortBean( it.asJsonObject["name"].asString, it.asJsonObject["path_word"].asString ) }, url = jsonObject["cover"].asString, authorList = jsonObject["authors"].asJsonArray.map { Author(it.asJsonObject["name"].asString, it.asJsonObject["path_word"].asString) }, isSubscribe = false, positionChapter = 0, positionPage = 0 ) }.getOrNull() fun getCoverEntry(): String? = jsonObject.has("cover_entry").let { if (it) jsonObject["cover_entry"].asString else null } fun setCoverEntry(name: String) { jsonObject.addProperty("cover_entry", name) } fun addChapter(chapter: LocalChapter, fileName: String?) { val jsonChapters = jsonObject["chapters"].asJsonObject if (!jsonChapters.has(chapter.uuid)) { jsonChapters.add(chapter.uuid) { JsonObject().also { it.apply { addProperty("chapter_name", chapter.name) addProperty("comic_path_word", chapter.comicPathWord) addProperty("chapter_is_reading", chapter.isReadProgress) addProperty("chapter_comic_id", chapter.comicId) addProperty("file_name", fileName) } } } } } fun removeChapters(chapter: LocalChapter): Boolean { return jsonObject["chapters"].asJsonObject.remove(chapter.uuid) != null } fun getChapters( vararg uuid: String, localSavableMangaModel: LocalSavableMangaModel, ): List { return localSavableMangaModel.list.filter { uuid.contains(it.uuid) } } fun getChapterJson(uuid: String): JsonObject? { return if (jsonObject.isJsonObject && jsonObject.has(uuid)) { jsonObject[uuid].asJsonObject } else null } override fun toString(): String { return jsonObject.toString() } companion object { private const val OUT_PUT_FILE = "" } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/DownloaderOutPutter.kt ================================================ package com.shicheeng.copymanga.server.download.domin import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import com.shicheeng.copymanga.fm.domain.makeDirIfNoExist import com.shicheeng.copymanga.util.KeyWordSwap import kotlinx.coroutines.runInterruptible import java.io.File class DownloaderOutPutter( private val rootFile: File, private val localSavableMangaModel: LocalSavableMangaModel, ) { private val rootFileDir = rootFile.makeDirIfNoExist() private val downloaderIndexer = DownloaderLocalIndex { File(rootFileDir, KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON) .also { it.createNewFile() } .takeIf { x -> x.exists() && x.canRead() } ?.readText() } init { downloaderIndexer.setMangaData(localSavableMangaModel, append = true) } suspend fun addCover(file: File, ext: String) { val name = buildString { append("cover") if (ext.isNotEmpty() && ext.length < 4) { append(".") append(ext) } } runInterruptible { file.copyTo(File(rootFile, name), overwrite = true) } downloaderIndexer.setCoverEntry(name) completedIndex() } suspend fun addPager( localChapter: LocalChapter, file: File, pagerNumber: Int, ext: String, ) { val name = buildString { append("/") append(localChapter.name) append("/") append("${localChapter.name}_") append(pagerNumber) if (ext.isNotEmpty() && ext.length < 4) { append(".") append(ext) } } runInterruptible { file.copyTo(File(rootFile, name), overwrite = true) } downloaderIndexer.addChapter(localChapter, name) } fun getDownloadChapters(array: Array) = downloaderIndexer.getChapters( uuid = array, localSavableMangaModel = localSavableMangaModel ) fun createNewLocalData(uuids: Array): LocalSavableMangaModel { return downloaderIndexer.getMangaData()?.let { LocalSavableMangaModel(it, getDownloadChapters(uuids)) } ?: error("出错") } private suspend fun completedIndex() = runInterruptible { File(rootFile, KeyWordSwap.LOCAL_SAVABLE_INDEX_JSON).writeText(downloaderIndexer.toString()) } suspend fun cleanUp() { completedIndex() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/PausingHandle.kt ================================================ package com.shicheeng.copymanga.server.download.domin import androidx.annotation.AnyThread import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first class PausingHandle { private val paused = MutableStateFlow(false) @get:AnyThread val isPaused: Boolean get() = paused.value @AnyThread suspend fun awaitResumed() { paused.filter { !it }.first() } @AnyThread fun pause() { paused.value = true } @AnyThread fun resume() { paused.value = false } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/domin/PausingHandler.kt ================================================ package com.shicheeng.copymanga.server.download.domin import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.os.PatternMatcher import androidx.core.app.PendingIntentCompat import com.shicheeng.copymanga.util.transformToUUIDMayNullSafety import java.util.UUID class PausingHandler( private val workerID: UUID, private val pausingHandle: PausingHandle, ) : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val uuid = intent?.getStringExtra(UUID_STRING).transformToUUIDMayNullSafety() if (uuid != workerID) return when (intent?.action) { ACTION_PAUSE -> pausingHandle.pause() ACTION_RESUME -> pausingHandle.resume() } } companion object { private const val UUID_STRING = "uuid" private const val ACTION_PAUSE = "com.shihcheeng.copymanga.download.PAUSE" private const val ACTION_RESUME = "com.shihcheeng.copymanga.download.RESUME" private const val SCHEME = "workuid" fun createIntentFilter(id: UUID) = IntentFilter().apply { addAction(ACTION_PAUSE) addAction(ACTION_RESUME) addDataScheme(SCHEME) addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) } fun getPauseIntent(context: Context, id: UUID) = Intent(ACTION_PAUSE) .setData(Uri.parse("$SCHEME://$id")) .setPackage(context.packageName) .putExtra(UUID_STRING, id.toString()) fun getResumeIntent(context: Context, id: UUID) = Intent(ACTION_RESUME) .setData(Uri.parse("$SCHEME://$id")) .setPackage(context.packageName) .putExtra(UUID_STRING, id.toString()) fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, 0, getPauseIntent(context, id), 0, false, ) fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, 0, getResumeIntent(context, id), 0, false, ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/woker/DownloadNotificationFactory.kt ================================================ package com.shicheeng.copymanga.server.download.woker import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.drawable.Drawable import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.TaskStackBuilder import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.work.WorkManager import coil.ImageLoader import coil.request.ErrorResult import coil.request.ImageRequest import coil.request.SuccessResult import coil.size.Scale import com.shicheeng.copymanga.MainActivity import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import com.shicheeng.copymanga.server.download.domin.DownloadState import com.shicheeng.copymanga.server.download.domin.PausingHandler import com.shicheeng.copymanga.ui.screen.Router import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.UUID private const val DOWNLOAD_CHANNEL_ID = "DOWNLOAD_CHANNEL" class DownloadNotificationFactory @AssistedInject constructor( @ApplicationContext private val context: Context, private val workerManager: WorkManager, private val coil: ImageLoader, @Assisted uuid: UUID, ) { private val downloadGroupID = "DOWNLOAD_GROUP" private val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) private val covers = HashMap() private val mutex = Mutex() private val coverWidth = context.resources.getDimensionPixelSize( androidx.core.R.dimen.compat_notification_large_icon_max_width, ) private val coverHeight = context.resources.getDimensionPixelSize( androidx.core.R.dimen.compat_notification_large_icon_max_height, ) private val downloadPending = TaskStackBuilder.create(context).run { addNextIntentWithParentStack( Intent( Intent.ACTION_VIEW, Router.DOWNLOAD.deepLink.toUri(), context, MainActivity::class.java ) ) getPendingIntent(0, PendingIntent.FLAG_MUTABLE) } private val actionCancel by lazy { NotificationCompat.Action( com.google.android.material.R.drawable.material_ic_clear_black_24dp, context.getString(android.R.string.cancel), workerManager.createCancelPendingIntent(uuid), ) } private val actionPause by lazy { NotificationCompat.Action( R.drawable.baseline_pause_24, context.getString(R.string.pause), PausingHandler.createPausePendingIntent(context, uuid), ) } private val actionResume by lazy { NotificationCompat.Action( R.drawable.baseline_play_arrow_24, context.getString(R.string.resume), PausingHandler.createResumePendingIntent(context, uuid), ) } init { bindNotification() builder.setSilent(true) builder.setDefaults(0) builder.setGroup(downloadGroupID) builder.setOnlyAlertOnce(true) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE builder.setGroupSummary(true) builder.setContentTitle(context.getString(R.string.downloading)) } suspend fun buildNotification(state: DownloadState?): Notification = mutex.withLock { if (state == null) { builder.setContentText(context.getString(R.string.preparing)) builder.setContentTitle(context.getString(R.string.downloading)) } else { builder.setContentTitle(state.localSavableMangaModel.mangaHistoryDataModel.name) builder.setContentText(context.getString(R.string.downloading)) } builder.setProgress(1, 0, true) builder.setSmallIcon(android.R.drawable.stat_sys_download) builder.setContentIntent(downloadPending) builder.setStyle(null) builder.setLargeIcon(if (state != null) getCover(state.localSavableMangaModel)?.toBitmap() else null) builder.clearActions() builder.setSubText(null) builder.setShowWhen(false) builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) when { state == null -> Unit state.localManga != null -> { builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.completed)) builder.setContentIntent(null) builder.setAutoCancel(true) builder.setSmallIcon(android.R.drawable.stat_sys_download_done) builder.setCategory(null) builder.setStyle(null) builder.setOngoing(false) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) } state.isStopped -> { builder.setProgress(0, 0, false) builder.setContentText(context.getString(R.string.waiting)) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) builder.setSmallIcon(R.drawable.ic_stat_name) builder.addAction(actionCancel) } state.isPaused -> { builder.setProgress(state.max, state.progress, false) val percent = if (state.percent >= 0) { reformatPercentString(percent = state.percent) } else { null } if (state.error != null) { builder.setContentText("$percent • ${state.error}") } else { builder.setContentText(percent) } builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) builder.setSmallIcon(R.drawable.baseline_pause_24) builder.addAction(actionCancel) builder.addAction(actionResume) } state.error != null -> { // error, final state builder.setProgress(0, 0, false) builder.setSmallIcon(android.R.drawable.stat_notify_error) builder.setSubText(context.getString(R.string.error)) builder.setContentText(state.error) builder.setAutoCancel(true) builder.setOngoing(false) builder.setCategory(NotificationCompat.CATEGORY_ERROR) builder.setShowWhen(true) builder.setWhen(System.currentTimeMillis()) builder.setStyle(NotificationCompat.BigTextStyle().bigText(state.error)) } else -> { builder.setProgress(state.max, state.progress, false) builder.setContentText(reformatPercentString(state.percent)) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) builder.setOngoing(true) builder.addAction(actionCancel) builder.addAction(actionPause) } } return builder.build() } private fun reformatPercentString(percent: Float): String { return "%.2f%%".format((percent * 100)) } private suspend fun getCover(localSavableMangaModel: LocalSavableMangaModel) = covers[localSavableMangaModel] ?: run { runCatching { coil.execute( ImageRequest.Builder(context) .data(localSavableMangaModel.mangaHistoryDataModel.url) .allowHardware(false) .tag(localSavableMangaModel.mangaHistoryDataModel.comicUUID) .size(coverWidth, coverHeight) .scale(Scale.FILL) .build() ).let { when (it) { is ErrorResult -> throw it.throwable is SuccessResult -> it.drawable } } }.onSuccess { covers[localSavableMangaModel] = it }.onFailure { it.printStackTrace() }.getOrNull() } private fun bindNotification() { val notificationManager = NotificationManagerCompat.from(context) val name = context.getString(R.string.download_channel_name) val importance = NotificationManager.IMPORTANCE_DEFAULT val mChannel = NotificationChannelCompat.Builder(DOWNLOAD_CHANNEL_ID, importance) .setName(name) .setVibrationEnabled(false) .setLightsEnabled(false) .setSound(null, null) .build() notificationManager.createNotificationChannel(mChannel) } @AssistedFactory interface Injket { fun create(uuid: UUID): DownloadNotificationFactory } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/download/woker/DownloadedWorker.kt ================================================ package com.shicheeng.copymanga.server.download.woker import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import android.webkit.MimeTypeMap import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorker import androidx.lifecycle.asFlow import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await import com.shicheeng.copymanga.data.LocalManga import com.shicheeng.copymanga.domin.DownloadFileDetectUtil import com.shicheeng.copymanga.fm.domain.PagerCache import com.shicheeng.copymanga.fm.domain.makeDirIfNoExist import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.resposity.MangaInfoRepository import com.shicheeng.copymanga.resposity.logD import com.shicheeng.copymanga.server.download.domin.DownloadState import com.shicheeng.copymanga.server.download.domin.DownloaderOutPutter import com.shicheeng.copymanga.server.download.domin.PausingHandle import com.shicheeng.copymanga.server.download.domin.PausingHandler import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.Throttler import com.shicheeng.copymanga.util.await import com.shicheeng.copymanga.util.messageNoNull import com.shicheeng.copymanga.util.progress.TimeLeftEstimator import com.shicheeng.copymanga.util.useWithContext import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import okio.buffer import okio.sink import java.io.File import java.io.IOException import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject @HiltWorker class DownloadedWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val mangaHistoryRepository: MangaHistoryRepository, private val pagerCache: PagerCache, private val mangaInfoRepository: MangaInfoRepository, private val detectUtil: DownloadFileDetectUtil, private val okHttpClient: OkHttpClient, downloadNotificationFactory: DownloadNotificationFactory.Injket, ) : CoroutineWorker(appContext, params) { private val notificationFactory = downloadNotificationFactory.create(params.id) private val notificationManager = appContext.getSystemService(NotificationManager::class.java) private val mutex = Mutex() private val throttler = Throttler(400) private val pausingHandle = PausingHandle() private val pausingHandler = PausingHandler(params.id, pausingHandle) private val timeLeftEstimator = TimeLeftEstimator() @Volatile private var _lastState: DownloadState? = null private val lastState: DownloadState get() = checkNotNull(_lastState) override suspend fun doWork(): Result { setForeground(getForegroundInfo()) val mangaPathWord = inputData.getString(MANGA_PATH_WORD) ?: return Result.failure() val manga = mangaHistoryRepository.getMangaByPathWord(mangaPathWord) ?: return Result.failure() val downloadedChapters = getDoneChapters() val mangaDownloadUUIDs = inputData.getStringArray(MANGA_DOWNLOAD_UUIDS) ?.takeUnless { it.isEmpty() } _lastState = DownloadState( localSavableMangaModel = manga, isIndeterminate = true ) return try { downloadMangaImpl( downloadUUID = mangaDownloadUUIDs, downloadedUUID = downloadedChapters ) Result.success() } catch (e: CancellationException) { withContext(NonCancellable) { val notification = notificationFactory.buildNotification(lastState.copy(isStopped = true)) notificationManager.notify(id.hashCode(), notification) } throw e } catch (e: IOException) { e.printStackTrace() Result.retry() } catch (e: Exception) { e.printStackTrace() Result.failure( lastState.copy( error = e.message, ).transformToWorkData() ) } finally { notificationManager.cancel(id.hashCode()) } } private suspend fun downloadMangaImpl( downloadUUID: Array?, downloadedUUID: Array, ) { requireNotNull(downloadUUID) { "下载的章节不可以为空" } val manga = lastState.localSavableMangaModel val chapterToSkip = downloadedUUID.toMutableList() mutex.withLock { ContextCompat.registerReceiver( applicationContext, pausingHandler, PausingHandler.createIntentFilter(id), ContextCompat.RECEIVER_NOT_EXPORTED ) val filePath = detectUtil.getRootFile(manga) val tmpFile = "${manga.mangaHistoryDataModel.name}_$id.tmp" val outPut: DownloaderOutPutter? try { outPut = DownloaderOutPutter(filePath, manga) val coverFile = manga.mangaHistoryDataModel.url downloadFile(url = coverFile, path = filePath, tmpFile = tmpFile).let { outPut.addCover(file = it, MimeTypeMap.getFileExtensionFromUrl(coverFile)) } val chapters = manga.list.filter { downloadUUID.contains(it.uuid) } for ((chapterIndex, chapter) in chapters.withIndex()) { if (chapterToSkip.remove(chapter.uuid)) { pushState( lastState.copy(downloadedChapters = lastState.downloadedChapters + chapter.uuid) ) continue } val pagerInfo = runDownloadPausingDetect(pausingHandle) { mangaInfoRepository.fetchContentMayLocal( localList = null, pathWord = chapter.comicPathWord, uuid = chapter.uuid ) } for ((pagerIndex, pager) in pagerInfo.withIndex()) { runDownloadPausingDetect(pausingHandle) { val page = pagerCache.get(url = pager.url) ?: downloadFile(url = pager.url, path = filePath, tmpFile) outPut.addPager( localChapter = chapter, file = page, pagerNumber = pager.index, ext = MimeTypeMap.getFileExtensionFromUrl(pager.url) ) pushState( lastState.copy( totalChapters = chapters.size, currentChapter = chapterIndex, isIndeterminate = false, totalPages = pagerInfo.size, currentPage = pagerIndex ) ) } } pushState( lastState.copy( downloadedChapters = lastState.downloadedChapters + chapter.uuid ) ) mangaHistoryRepository.updateLocalChapter( chapter.copy(isDownloaded = true) ) } pushState( lastState.copy(isIndeterminate = true) ) val localManga = outPut.createNewLocalData(downloadUUID) pushState( lastState.copy(localManga = LocalManga(localManga, filePath)) ) outPut.cleanUp() } catch (e: Exception) { if (e !is CancellationException) { pushState( lastState.copy(error = e.message) ) } throw e } finally { withContext(NonCancellable) { applicationContext.unregisterReceiver(pausingHandler) File(filePath, tmpFile).apply { withContext(Dispatchers.IO) { delete() || deleteRecursively() } } } } } } private suspend fun downloadFile( url: String, path: File, tmpFile: String, ): File { val request: Request = Request.Builder().url(url).build() val call = okHttpClient.newCall(request) val response = call.clone().await() val file = File(path, tmpFile).also { withContext(Dispatchers.IO) { it.createNewFile() } } checkNotNull(response.body).use { body -> file.sink(append = false).buffer().useWithContext(Dispatchers.IO) { it.writeAll(body.source()) } } return file } override suspend fun getForegroundInfo(): ForegroundInfo { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ForegroundInfo( id.hashCode(), notificationFactory.buildNotification(_lastState), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) } else { ForegroundInfo( id.hashCode(), notificationFactory.buildNotification(_lastState), ) } } private suspend fun getDoneChapters(): Array { val work = WorkManager.getInstance(applicationContext).getWorkInfoById(id).await() ?: return emptyArray() return DownloadState downloadChaptersIn work.progress } private suspend fun pushState(state: DownloadState) { val previous = lastState _lastState = state if (previous.isParticularProgress && state.isParticularProgress) { timeLeftEstimator.tick(state.progress, state.max) } else { timeLeftEstimator.emptyTick() throttler.reset() } val notification = notificationFactory.buildNotification(state) if (state.isFinalState) { notificationManager.notify(id.toString(), id.hashCode(), notification) } else if (throttler.throttle()) { notificationManager.notify(id.hashCode(), notification) } else { return } setProgress(data = state.transformToWorkData()) } private suspend fun runDownloadPausingDetect( pausingHandle: PausingHandle, block: suspend () -> R, ): R { if (pausingHandle.isPaused) { pushState(lastState.copy(isPaused = true)) pausingHandle.awaitResumed() pushState(lastState.copy(isPaused = false)) } var countDown = MAX_FAILSAFE_ATTEMPTS detect@ while (true) { try { return block() } catch (e: IOException) { if (countDown <= 0) { pushState(lastState.copy(isPaused = true, error = e.messageNoNull)) countDown = MAX_FAILSAFE_ATTEMPTS pausingHandle.pause() pausingHandle.awaitResumed() pushState(lastState.copy(isPaused = false, error = null)) } else { countDown-- delay(200L) } } } } class Caller @Inject constructor( @ApplicationContext private val context: Context, private val workManager: WorkManager, private val setting: SettingPref, ) { suspend fun download(pathWord: String, downloadUUIDs: Array) { if (downloadUUIDs.isEmpty()) return val data = Data.Builder() .putString(MANGA_PATH_WORD, pathWord) .putStringArray(MANGA_DOWNLOAD_UUIDS, downloadUUIDs) .build() scheduleImpl(listOf(data)) } fun observerWorker() = workManager.getWorkInfosByTagLiveData(TAG) .asFlow() suspend fun cancel(uuid: UUID) { workManager.cancelWorkById(uuid).await() } private suspend fun scheduleImpl(data: Collection) { if (data.isEmpty()) { return } val constraints = createConstraints() val requests = data.map { inputData -> OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG) .keepResultsForAtLeast(30, TimeUnit.DAYS) .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) .setInputData(inputData) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } workManager.enqueue(requests).await() } fun pause(id: UUID) { val intent = PausingHandler.getPauseIntent(context, id) context.sendBroadcast(intent) } fun resume(id: UUID) { val intent = PausingHandler.getResumeIntent(context, id) context.sendBroadcast(intent) } suspend fun updateConstraints() { val constraints = createConstraints() val works = workManager.getWorkInfosByTag(TAG).await() for (work in works) { if (work.state.isFinished) { continue } val request = OneTimeWorkRequestBuilder() .setConstraints(constraints) .addTag(TAG) .setId(work.id) .build() workManager.updateWork(request).await() } } private fun createConstraints() = Constraints.Builder() .setRequiredNetworkType(if (setting.downloadOnlyOnWifi) NetworkType.UNMETERED else NetworkType.CONNECTED) .build() } companion object { private const val MANGA_PATH_WORD = "MANGA_PATH_WORD" private const val MANGA_DOWNLOAD_UUIDS = "MANGA_DOWNLOAD_UUIDS" const val MAX_FAILSAFE_ATTEMPTS = 2 private const val TAG = "download_worker" } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/work/DetectMangaUpdateWork.kt ================================================ package com.shicheeng.copymanga.server.work import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.TaskStackBuilder import androidx.core.net.toUri import androidx.hilt.work.HiltWorker import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.shicheeng.copymanga.MainActivity import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.resposity.MangaInfoRepository import com.shicheeng.copymanga.ui.screen.setting.IN_BATTERY_NOT_LOW import com.shicheeng.copymanga.ui.screen.setting.IN_CHARGING import com.shicheeng.copymanga.ui.screen.setting.IN_WIFI import com.shicheeng.copymanga.ui.screen.setting.SettingPref import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.io.IOException import java.util.concurrent.TimeUnit @HiltWorker class DetectMangaUpdateWork @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted params: WorkerParameters, infoRepository: MangaInfoRepository, historyRepository: MangaHistoryRepository, ) : CoroutineWorker(appContext, params), IDetectManga.OnMangaDetectUpdate { private val iDetectManga = IDetectManga(historyRepository, infoRepository, this) private val notificationManager = appContext.getSystemService(NotificationManager::class.java) private val notification = NotificationCompat.Builder(appContext, DETECT_UPDATE_CHANELLE).apply { setContentTitle(appContext.getString(R.string.update_manga)) setSmallIcon(R.drawable.ic_stat_name) setContentText(appContext.getString(R.string.preparing)) setProgress(0, 0, true) setOngoing(true) setDefaults(0) setGroup(GROUP_ITEM_CHAPTERS) setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) setGroupSummary(true) setOnlyAlertOnce(true) priority = NotificationCompat.PRIORITY_HIGH } override suspend fun doWork(): Result { notificationManager.notify( DETECT_UPDATE_NOTIFICATION_ID, notification.build() ) return try { iDetectManga.fetchMangaUpdate() Result.success() } catch (e: IOException) { e.printStackTrace() Result.retry() } catch (e: Exception) { e.printStackTrace() onError() Result.failure() } } override fun onReady() { notificationManager.notify( DETECT_UPDATE_NOTIFICATION_ID, notification.build() ) } override fun onSubscribe(index: Int, size: Int, historyDataModel: MangaHistoryDataModel) { notification.setProgress(size, index + 1, false) notification.setContentText(historyDataModel.name) notificationManager.notify( DETECT_UPDATE_NOTIFICATION_ID, notification.build() ) } override fun onError( eIndex: Int, historyDataModel: MangaHistoryDataModel, exception: Throwable, ) { exception.printStackTrace() } override fun onSingleSuccess( index: Int, historyDataModel: MangaHistoryDataModel, newChapter: List, ) { if (newChapter.isNotEmpty()) { val pathWord = historyDataModel.pathWord val link = "shicheengcmdm://detail/$pathWord" val deepLinkIntent = Intent( Intent.ACTION_VIEW, link.toUri(), appContext, MainActivity::class.java ) val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(appContext).run { addNextIntentWithParentStack(deepLinkIntent) getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) } val notificationItem = NotificationCompat .Builder(appContext, DETECT_UPDATE_CHANELLE) .apply { setContentTitle(historyDataModel.name) setContentText(newChapter.joinToString { it.name }) setSmallIcon(R.drawable.ic_outline_page) setOngoing(false) setGroup(GROUP_ITEM_CHAPTERS) setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) setContentIntent(deepLinkPendingIntent) }.build() notificationManager.notify(historyDataModel.hashCode(), notificationItem) } } override fun onSuccess() { notification.setProgress(0, 0, false) notification.setContentText(appContext.getString(R.string.completed)) notification.setOngoing(false) notification.setAutoCancel(true) notificationManager.notify( DETECT_UPDATE_NOTIFICATION_ID, notification.build() ) } private fun onError() { notification.setProgress(0, 0, false) notification.setContentText(appContext.getString(R.string.fatal_error)) notification.setOngoing(false) notification.setAutoCancel(true) notificationManager.notify( DETECT_UPDATE_NOTIFICATION_ID, notification.build() ) } companion object { const val DETECT_UPDATE_CHANELLE = "DETECT_UPDATE_CHANELLE" private const val DETECT_UPDATE_NOTIFICATION_ID = 0x1a2f3c private const val GROUP_ITEM_CHAPTERS = "GROUP_ITEM_CHAPTERS" private const val Tag = "Manga Update Task" /** * 启动这个Worker */ fun readyToStart( isEnable: Boolean, context: Context, settingPref: SettingPref, takeInterval: Int? = null, ) { if (isEnable) { val interval = takeInterval ?: settingPref.timeInterval.value if (interval > 0) { val constraintsSetting = settingPref.updateConstant.value val constraints = Constraints.Builder() .setRequiresCharging(IN_CHARGING in constraintsSetting) .setRequiredNetworkType( if (IN_WIFI in constraintsSetting) NetworkType.UNMETERED else NetworkType.CONNECTED ) .setRequiresBatteryNotLow(IN_BATTERY_NOT_LOW in constraintsSetting) .build() val work = PeriodicWorkRequestBuilder( repeatInterval = interval.toLong(), repeatIntervalTimeUnit = TimeUnit.HOURS, flexTimeInterval = 10, flexTimeIntervalUnit = TimeUnit.MINUTES ) .addTag(Tag) .setConstraints(constraints) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( Tag, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, work ) } } else { WorkManager.getInstance(context).cancelAllWorkByTag(Tag) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/server/work/IDetectManga.kt ================================================ package com.shicheeng.copymanga.server.work import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.resposity.MangaInfoRepository import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class IDetectManga( private val repository: MangaHistoryRepository, private val infoRepository: MangaInfoRepository, private val onMangaDetectUpdate: OnMangaDetectUpdate, ) { private val mutex = Mutex() suspend fun fetchMangaUpdate() { onMangaDetectUpdate.onReady() val totalMangas = repository.totalHistoryManga().filter { it.isSubscribe } totalMangas.forEachIndexed { index, mangaHistoryDataModel -> mutex.withLock { try { onMangaDetectUpdate.onSubscribe( index = index, size = totalMangas.size, historyDataModel = mangaHistoryDataModel ) val oldList = repository .fetchMangaChapterByPathWord(mangaHistoryDataModel.pathWord) val list = infoRepository .fetchMangaChaptersForce(mangaHistoryDataModel.pathWord) oldList?.let { val newChapter = list.filterNot { y -> it.any { x -> x.uuid == y.uuid } } onMangaDetectUpdate.onSingleSuccess( index = index, historyDataModel = mangaHistoryDataModel, newChapter = newChapter ) } } catch (e: Exception) { onMangaDetectUpdate.onError(index, mangaHistoryDataModel, e) } } } onMangaDetectUpdate.onSuccess() } interface OnMangaDetectUpdate { fun onReady() fun onSubscribe(index: Int, size: Int, historyDataModel: MangaHistoryDataModel) fun onError(eIndex: Int, historyDataModel: MangaHistoryDataModel, exception: Throwable) fun onSingleSuccess( index: Int, historyDataModel: MangaHistoryDataModel, newChapter: List, ) fun onSuccess() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/MainNavigation.kt ================================================ package com.shicheeng.copymanga.ui.screen import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navDeepLink import com.shicheeng.copymanga.ui.screen.Router.COMMENT.toCommentScreen import com.shicheeng.copymanga.ui.screen.Router.EXPLORE.toExplore import com.shicheeng.copymanga.ui.screen.Router.HISTORY.toHistory import com.shicheeng.copymanga.ui.screen.authorsmanga.AuthorsMangaScreen import com.shicheeng.copymanga.ui.screen.comment.CommentScreen import com.shicheeng.copymanga.ui.screen.download.DownloadScreen import com.shicheeng.copymanga.ui.screen.downloaded.DownloadedScreen import com.shicheeng.copymanga.ui.screen.history.HistoryScreen import com.shicheeng.copymanga.ui.screen.list.NewestScreen import com.shicheeng.copymanga.ui.screen.list.RecommendScreen import com.shicheeng.copymanga.ui.screen.login.LoginScreen import com.shicheeng.copymanga.ui.screen.login.loginlist.LoginPersonalListScreen import com.shicheeng.copymanga.ui.screen.main.MainScreen import com.shicheeng.copymanga.ui.screen.main.explore.ExploreScreen import com.shicheeng.copymanga.ui.screen.main.home.search.SearchScreen import com.shicheeng.copymanga.ui.screen.main.personal.personaldetail.PersonalDetail import com.shicheeng.copymanga.ui.screen.main.subscribe.SubScribeScreen import com.shicheeng.copymanga.ui.screen.manga.MangaDetailScreen import com.shicheeng.copymanga.ui.screen.search.SearchResultScreen import com.shicheeng.copymanga.ui.screen.setting.SettingScreen import com.shicheeng.copymanga.ui.screen.setting.about.AboutScreen import com.shicheeng.copymanga.ui.screen.setting.worker.WorkerScreen import com.shicheeng.copymanga.ui.screen.topiclist.TopicListScreen import com.shicheeng.copymanga.ui.screen.topics.TopicsScreen import com.shicheeng.copymanga.ui.screen.webshelf.WebShelfScreen import soup.compose.material.motion.animation.materialSharedAxisXIn import soup.compose.material.motion.animation.materialSharedAxisXOut import soup.compose.material.motion.animation.rememberSlideDistance @Composable fun MainComposeNavigation( navController: NavHostController = rememberNavController(), ) { val slide = rememberSlideDistance() NavHost( navController = navController, startDestination = Router.MAIN.name, enterTransition = { materialSharedAxisXIn( forward = true, slideDistance = slide ) }, exitTransition = { materialSharedAxisXOut( forward = true, slideDistance = slide ) }, popEnterTransition = { materialSharedAxisXIn( forward = false, slideDistance = slide ) }, popExitTransition = { materialSharedAxisXOut( forward = false, slideDistance = slide ) } ) { composable( route = Router.MAIN.name, ) { MainScreen( onUUid = { navController.navigate("${Router.DETAIL.name}/$it") }, onDownloadedBtnClick = { navController.navigate(Router.DOWNLOADED.name) }, onSearchButtonClick = { navController.navigate(Router.SEARCH.name) }, onSettingButtonClick = { navController.navigate(Router.SETTING.name) }, onRecommendHeaderLineClick = { navController.navigate(Router.RECOMMEND.name) }, onNewestHeaderLineClick = { navController.navigate(Router.NEWEST.name) }, onSubscribedClick = { navController.navigate(Router.SUBSCRIBE.name) }, onHistoryClick = { navController.toHistory() }, onLibraryClick = { navController.navigate(Router.WebSHELF.name) }, onPersonalHeaderClick = { login -> if (login) { navController.navigate(Router.UserShortDETAIL.name) } else { navController.navigate(Router.LOGIN.name) } }, onTopicClick = { pathWord, type -> navController.navigate(Router.TopicDETAIL.pathWord(pathWord, type)) }, onTopicHeaderLineClick = { navController.navigate(Router.TOPICS.name) }, onFinishHeaderLineClick = { navController.toExplore( theme = null, top = "finish", order = null, ) }, onLoginExpireClick = { navController.navigate(Router.LOGIN.name) } ) { navController.toExplore( theme = null, top = null, order = "-popular", ) } } composable( route = "${Router.EXPLORE.name}?theme={theme}&top={top}&order={order}", arguments = listOf( navArgument(name = "theme") { nullable = true }, navArgument(name = "top") { nullable = true }, navArgument(name = "order") { nullable = true } ) ) { backStackEntry -> ExploreScreen( top = backStackEntry.arguments?.getString("top"), theme = backStackEntry.arguments?.getString("theme"), order = backStackEntry.arguments?.getString("order"), onNavigationIconClick = { navController.popBackStack() } ) { navController.navigate("${Router.DETAIL.name}/${it.pathWord}") } } composable(route = Router.RECOMMEND.name) { RecommendScreen( onBack = { navController.popBackStack() } ) { navController.navigate("${Router.DETAIL.name}/$it") } } composable(route = Router.NEWEST.name) { NewestScreen( onBack = { navController.popBackStack() } ) { navController.navigate("${Router.DETAIL.name}/$it") } } composable(route = Router.SEARCH.name) { SearchScreen( onSearch = { if (it.isNotEmpty() && it.isNotBlank()) { navController.navigate("${Router.SearchResult.name}/$it") } } ) { navController.popBackStack() } } composable( route = "${Router.SearchResult.name}/{searchWord}" ) { navBackStackEntry -> val word = navBackStackEntry.arguments?.getString("searchWord") SearchResultScreen( searchWord = word, onNavigation = { navController.popBackStack() }, onItemClick = { navController.navigate("${Router.DETAIL.name}/${it.pathWord}") } ) } composable( route = "${Router.DETAIL.name}/{path_word}", deepLinks = listOf( navDeepLink { uriPattern = Router.DETAIL.deepLink }, navDeepLink { uriPattern = Router.DETAIL.copyMangaWebURl } ) ) { backStackEntry -> val pathWord = backStackEntry.arguments?.getString("path_word") MangaDetailScreen( pathWord = pathWord, onTagsClick = { navController.toExplore( top = null, order = null, theme = it.pathWord ) }, onAuthorClick = { navController.navigate("${Router.AuthorsMANGA.name}/${it}") }, onCommentClick = { navController toCommentScreen it } ) { navController.popBackStack() } } composable( route = Router.SETTING.name ) { SettingScreen( onNavigateClick = { navController.popBackStack() }, onDownloadClick = { navController.navigate(Router.DOWNLOAD.name) }, onWorkerClick = { navController.navigate(Router.WORKER.name) }, onUserClick = { navController.navigate(Router.LoginSelect.name) } ) { navController.navigate(Router.ABOUT.name) } } composable( route = Router.DOWNLOAD.name, deepLinks = listOf( navDeepLink { uriPattern = Router.DOWNLOAD.deepLink } ) ) { DownloadScreen( onNavigationClick = navController::popBackStack, onCardClick = { navController.navigate("${Router.DETAIL.name}/$it") } ) } composable( route = Router.ABOUT.name ) { AboutScreen { navController.popBackStack() } } composable( route = Router.WORKER.name ) { WorkerScreen { navController.popBackStack() } } composable(route = Router.DOWNLOADED.name) { DownloadedScreen( onNavigate = { navController.popBackStack() } ) { pathWord -> if (pathWord != null) { navController.navigate("${Router.DETAIL.name}/$pathWord") } } } composable(route = Router.HISTORY.name) { HistoryScreen( navigationClick = { navController.popBackStack() }, onRequestLogin = { navController.navigate(Router.LOGIN.name) } ) { pathWord -> navController.navigate("${Router.DETAIL.name}/$pathWord") } } composable(route = Router.SUBSCRIBE.name) { SubScribeScreen( navClick = { navController.popBackStack() } ) { pathWord -> navController.navigate("${Router.DETAIL.name}/$pathWord") } } composable( route = "${Router.TopicDETAIL.name}/{pathWord}?type={type}", arguments = listOf( navArgument(name = "pathWord") { type = NavType.StringType }, navArgument(name = "type") { type = NavType.IntType } ) ) { TopicsScreen( onBack = { navController.popBackStack() } ) { navController.navigate("${Router.DETAIL.name}/${it}") } } composable( route = Router.TOPICS.name, ) { TopicListScreen( onBack = { navController.popBackStack() } ) { navController.navigate(Router.TopicDETAIL.pathWord(it.pathWord, it.type)) } } composable(route = Router.LOGIN.name) { LoginScreen( onNavClick = { navController.popBackStack() } ) { navController.popBackStack() } } composable(route = Router.LoginSelect.name) { LoginPersonalListScreen( onAddClicked = { navController.navigate(Router.LOGIN.name) } ) { navController.popBackStack() } } composable(route = Router.WebSHELF.name) { WebShelfScreen( navClick = { navController.popBackStack() }, reLoginClick = { navController.navigate(Router.LOGIN.name) } ) { navController.navigate("${Router.DETAIL.name}/${it}") } } composable(route = Router.UserShortDETAIL.name) { PersonalDetail( onReLogin = { navController.navigate(Router.LOGIN.name) } ) { navController.popBackStack() } } composable( route = Router.AuthorsMANGA.name + "/{author_path_word}", arguments = listOf( navArgument(name = "author_path_word") { nullable = true type = NavType.StringType } ) ) { AuthorsMangaScreen( onNav = { navController.popBackStack() } ) { navController.navigate("${Router.DETAIL.name}/${it}") } } composable(route = Router.COMMENT.name + "/{uuid_comic}") { CommentScreen( navClick = navController::popBackStack ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/Router.kt ================================================ package com.shicheeng.copymanga.ui.screen import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.navigation.NavHostController import com.shicheeng.copymanga.R /** * 导航路由 * @param name 必传参数,名字。 * @param stringId 非必传参数,适用于导航栏的字串符资源ID。 * @param drawableRes 非必传参数,适用于导航栏的图标资源ID。 * @param onClickIcon 非必传参数,但是如果传入[drawableRes]则必传,否则报错。在导航栏按钮被按下时显示的图标。 */ sealed class Router( val name: String, @StringRes val stringId: Int? = null, @DrawableRes val drawableRes: Int? = null, @DrawableRes val onClickIcon: Int? = null, ) { object MAIN : Router( name = "MAIN" ) object HOME : Router( name = "HOME", stringId = R.string.home_des, drawableRes = R.drawable.outline_home_24, onClickIcon = R.drawable.ic_baseline_home_24 ) object LEADERBOARD : Router( name = "LEADERBOARD", stringId = R.string.comic_rank, drawableRes = R.drawable.baseline_insert_chart_outlined_24, onClickIcon = R.drawable.baseline_insert_chart_24 ) object EXPLORE : Router( name = "EXPLORE", stringId = R.string.explore, drawableRes = R.drawable.ic_explore_outline, onClickIcon = R.drawable.baseline_explore_24 ) { /** * 转到[EXPLORE]界面。 * * @param top 话题 * @param theme 主题 * @param order 排序 */ fun NavHostController.toExplore(top: String?, theme: String?, order: String?) { navigate(name + "?theme=${theme}&top=${top}&order=${order}") } } object SUBSCRIBE : Router(name = "SUBSCRIBE") object HISTORY : Router(name = "HISTORY") { fun NavHostController.toHistory() { navigate(name) } } object PERSONAL : Router( name = "PERSONAL", stringId = R.string.personal, drawableRes = R.drawable.ic_person_center, onClickIcon = R.drawable.baseline_person_24 ) object DOWNLOADED : Router( name = "DOWNLOADED" ) object RECOMMEND : Router(name = "RECOMMEND") object NEWEST : Router(name = "NEWEST") object DETAIL : Router(name = "DETAIL") { const val deepLink = "shicheengcmdm://detail/{path_word}" const val copyMangaWebURl = "https://copymanga.site/h5/details/comic/{path_word}" } object SEARCH : Router(name = "SEARCH") object SearchResult : Router(name = "SearchResult") object SETTING : Router(name = "SETTING") object WORKER : Router(name = "WORKER") object DOWNLOAD : Router(name = "DOWNLOAD") { const val deepLink = "shicheengcmdm://download" } object ABOUT : Router(name = "ABOUT") object TOPICS : Router(name = "TOPIC") object TopicDETAIL : Router("TOPIC_DETAIL") { fun pathWord(pathWord: String, type: Int): String { return this.name + "/${pathWord}?type=$type" } } object LOGIN : Router("LOGIN") object LoginSelect : Router("LoginSelect") object WebSHELF : Router("WebSHELF") object UserShortDETAIL : Router("UserShortDETAIL") object AuthorsMANGA : Router("AuthorsMANGA") object COMMENT : Router("COMMENT") { private fun comicUUid(uuid: String): String { return this.name + "/$uuid" } infix fun NavHostController.toCommentScreen(uuid: String) { navigate(comicUUid(uuid)) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/authorsmanga/AuthorsMangaScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.authorsmanga import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.ui.screen.list.CommonListItem import com.shicheeng.copymanga.util.copyComposable import com.shicheeng.copymanga.viewmodel.AuthorMangaViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun AuthorsMangaScreen( viewModel: AuthorMangaViewModel = hiltViewModel(), onNav: () -> Unit, onPathWord: (String) -> Unit, ) { val data = viewModel.list.collectAsLazyPagingItems() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.authors_manga)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNav ) } ) } ) { paddingValues -> LazyVerticalGrid( contentPadding = paddingValues.copyComposable( start = 16.dp, end = 16.dp ), verticalArrangement = Arrangement.spacedBy(16.dp), columns = GridCells.Fixed(3), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { items(data.itemCount) { index -> data[index]?.let { mangaItem -> CommonListItem( url = mangaItem.cover, title = mangaItem.name, author = mangaItem.author.joinToString { it.name } ) { onPathWord(mangaItem.pathWord) } } } pagingLoadingIndication( loadState = data.loadState.append, onTry = data::retry ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/comment/CommentItem.kt ================================================ package com.shicheeng.copymanga.ui.screen.comment import androidx.compose.foundation.layout.Arrangement 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.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.shicheeng.copymanga.data.mangacomment.MangaCommentListItem @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommentItem( commentListItem: MangaCommentListItem, onClick: () -> Unit, ) { OutlinedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), onClick = { onClick() } ) { Column( modifier = Modifier.padding(16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { AsyncImage( model = commentListItem.userAvatar, contentDescription = null, modifier = Modifier .padding(8.dp) .size(32.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) Column( modifier = Modifier.padding(bottom = 4.dp), verticalArrangement = Arrangement.Center ) { Text( text = commentListItem.userName, style = MaterialTheme.typography.titleMedium ) Text( text = commentListItem.createAt, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } HorizontalDivider() Text( text = commentListItem.comment, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 8.dp) ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/comment/CommentScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.comment import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.EmptyDataScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.SwipeRefresh import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.rememberSwipeRefreshState import com.shicheeng.copymanga.util.SendUIState import com.shicheeng.copymanga.viewmodel.CommentViewModel @OptIn( ExperimentalMaterial3Api::class ) @Composable fun CommentScreen( viewModel: CommentViewModel = hiltViewModel(), navClick: () -> Unit, ) { val list = viewModel.comments.collectAsLazyPagingItems() val pullRefreshState = rememberSwipeRefreshState( isRefreshing = list.loadState.refresh is LoadState.Loading, ) val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val (sendContent, onSendContent) = rememberSaveable { mutableStateOf("") } val commentStatus by viewModel.commentPush.collectAsState() val isExpired by viewModel.loginIsExpired.collectAsState() LaunchedEffect(key1 = commentStatus) { if (commentStatus is SendUIState.Success) { list.refresh() } } Scaffold( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.ime), topBar = { TopAppBar( title = { Text(text = stringResource(R.string.comic_comment_title)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = navClick ) }, scrollBehavior = topAppBarScrollBehavior, modifier = Modifier, windowInsets = WindowInsets.statusBars ) }, bottomBar = { CommentSendBar( value = sendContent, onValueChange = onSendContent, sendUIState = commentStatus, modifier = Modifier, isExpired = isExpired ) { viewModel.sendComment(sendContent) } } ) { padding -> SwipeRefresh( state = pullRefreshState, onRefresh = { list.refresh() }, indicatorPadding = padding, ) { EmptyDataScreen( isEmpty = list.itemSnapshotList.isEmpty(), modifier = Modifier ) { LazyColumn( contentPadding = padding, verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxSize() .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { items(list.itemCount) { list[it]?.let { commentItem -> CommentItem(commentListItem = commentItem) { } } } pagingLoadingIndication( loadState = list.loadState.append, onTry = list::retry ) } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/comment/CommentSendBar.kt ================================================ package com.shicheeng.copymanga.ui.screen.comment import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.commentpush.CommentPushDataModel import com.shicheeng.copymanga.ui.theme.ElevationTokens import com.shicheeng.copymanga.util.SendUIState import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughOut @Composable fun CommentSendBar( modifier: Modifier = Modifier, value: String, isExpired:Boolean, onValueChange: (String) -> Unit, sendUIState: SendUIState, onSend: () -> Unit, ) { Surface( tonalElevation = ElevationTokens.Level4, shadowElevation = ElevationTokens.Level2, modifier = modifier .zIndex(1f) ) { Column( modifier = Modifier .padding( bottom = 8.dp, top = 8.dp, end = 16.dp, start = 16.dp ) .navigationBarsPadding() .imePadding() .animateContentSize() ) { Text( text = stringResource(R.string.send_comment_bar_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp) ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, modifier = Modifier ) { BasicTextField( value = value, onValueChange = onValueChange, modifier = Modifier .weight(1f) .defaultMinSize( minWidth = ButtonDefaults.MinWidth, minHeight = ButtonDefaults.MinHeight ), textStyle = MaterialTheme.typography.bodyMedium .copy( color = MaterialTheme.colorScheme.onSurface ) ) { Surface( shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, tonalElevation = ElevationTokens.Level0, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) { Box( modifier = Modifier.padding( horizontal = 16.dp, vertical = 8.dp ), contentAlignment = Alignment.CenterStart, ) { it() this@Row.AnimatedVisibility( visible = value.isEmpty(), enter = materialFadeThroughIn(), exit = materialFadeThroughOut() ) { Text( text = stringResource(R.string.type_send_content), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium ) } } } } Spacer(modifier = Modifier.width(8.dp)) FilledTonalButton( onClick = onSend, enabled = sendUIState == SendUIState.Idle && !isExpired ) { when (sendUIState) { is SendUIState.Error -> { Icon( painter = painterResource(id = R.drawable.baseline_close_24), contentDescription = null ) } SendUIState.Idle -> { Icon( painter = painterResource(id = R.drawable.baseline_send_24), contentDescription = null ) } SendUIState.Loading -> { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } is SendUIState.Success -> { Icon( painter = painterResource(id = R.drawable.ic_done_all), contentDescription = null ) } } Spacer(modifier = Modifier.width(4.dp)) Text(text = stringResource(R.string.send_comment)) } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/CircleLoadingButton.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.theme.ElevationTokens @Composable fun CircleLoadingButton( modifier: Modifier = Modifier, isLoading: Boolean, onClick: () -> Unit, tonalElevation: Dp = ElevationTokens.Level3, ) { Surface( onClick = onClick, shape = CircleShape, modifier = modifier.size(68.dp), tonalElevation = tonalElevation ) { AnimatedContent( targetState = isLoading, label = "circle loading" ) { if (it) { CircularProgressIndicator( modifier = Modifier.padding(16.dp) ) } else { Icon( painter = painterResource(id = R.drawable.undraw_arrow), contentDescription = stringResource(id = R.string.login_text), modifier = Modifier.padding(16.dp) ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/Components.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp /** * 带有[PlainTooltipBox]的[IconButton]。 * @param id String的资源ID, * @param drawableRes 图片的资源id, * @param onButtonClick 点击事件回调。 */ @Composable @OptIn(ExperimentalMaterial3Api::class) fun PlainButton( modifier: Modifier = Modifier, @StringRes id: () -> Int, @DrawableRes drawableRes: () -> Int, onButtonClick: () -> Unit, ) { TooltipBox( tooltip = { NormalTooltip(text = stringResource(id = id())) }, modifier = modifier, positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), state = rememberTooltipState() ) { IconButton( onClick = onButtonClick, modifier = Modifier ) { Icon( painter = painterResource(id = drawableRes()), contentDescription = stringResource(id = id()) ) } } } /** * 带有[PlainTooltipBox]的[IconButton]。 * @param id String的资源ID, * @param drawableRes 图片的资源id, * @param onButtonClick 点击事件回调。 */ @Composable fun PlainButton( @StringRes id: Int, @DrawableRes drawableRes: Int, onButtonClick: () -> Unit, ) { PlainButton(id = { id }, drawableRes = { drawableRes }, onButtonClick = onButtonClick) } @Composable private fun NormalTooltip( modifier: Modifier = Modifier, text: String, ) { Surface( contentColor = MaterialTheme.colorScheme.tertiaryContainer, color = MaterialTheme.colorScheme.onTertiaryContainer, shape = MaterialTheme.shapes.extraSmall, modifier = modifier ) { Text( text = text, style = MaterialTheme.typography.titleSmall .copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp) ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/ComposeExt.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import android.util.TypedValue import androidx.annotation.AttrRes import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBarState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.Dp import com.shicheeng.copymanga.ui.theme.ElevationTokens /** * 从Attr中引入资源 * @param attrResId 资源ID。 */ @Composable @ReadOnlyComposable internal fun dimensionAttribute( @AttrRes attrResId: Int, ) = dimensionResource(TypedValue().apply { LocalContext.current.theme.resolveAttribute( attrResId, this, true ) }.resourceId) @OptIn(ExperimentalMaterial3Api::class) @Composable fun withAppBarColor( backgroundColor: Color = MaterialTheme.colorScheme.surface, topAppBarState: TopAppBarState, ): Color { val colorTransitionFraction = topAppBarState.overlappedFraction val fraction = if (colorTransitionFraction > 0.01f) 1f else 0f val appBarContainerColor by animateColorAsState( targetValue = lerp( start = backgroundColor, stop = MaterialTheme.colorScheme.applyTonalElevation( backgroundColor = backgroundColor, elevation = ElevationTokens.Level2 ), fraction = FastOutLinearInEasing.transform(fraction) ), animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "color" ) return appBarContainerColor } fun ColorScheme.applyTonalElevation(backgroundColor: Color, elevation: Dp): Color { return if (backgroundColor == surface) { surfaceColorAtElevation(elevation) } else { backgroundColor } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/EasyCover.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.foundation.layout.aspectRatio import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage @Composable fun CommonCover( url: String, contentDescription: String, shape: Shape = MaterialTheme.shapes.medium, ) { AsyncImage( model = url, contentDescription = contentDescription, placeholder = ColorPainter(MaterialTheme.colorScheme.primary), modifier = Modifier .aspectRatio(2f / 3f) .clip(shape), contentScale = ContentScale.Crop ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/EmptyDataScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.shicheeng.copymanga.R @Composable fun EmptyDataScreen( modifier: Modifier = Modifier, tipText: String = stringResource(id = R.string.no_content), isEmpty: Boolean, content: @Composable () -> Unit, ) { Box( modifier = modifier .fillMaxSize(), ) { if (isEmpty) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.undraw_no_data_re_kwbl), contentDescription = null, ) Text(text = tipText) } } } else { content() } } } @Composable fun EmptyDataScreen( modifier: Modifier = Modifier, tipText: String = stringResource(id = R.string.no_content), ) { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.undraw_no_data_re_kwbl), contentDescription = null, ) Text(text = tipText) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/LoadingScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState import com.shicheeng.copymanga.R @Composable fun LoadingScreen() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } @Composable fun ErrorScreen( errorMessage: String, onTry: () -> Unit, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .padding(all = 16.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = errorMessage) FilledTonalButton(onClick = onTry) { Text(text = stringResource(id = R.string.retry)) } } } } @Composable fun ErrorScreen( errorMessage: String, onTry: () -> Unit, secondaryText: String, onSecondaryClick: () -> Unit = { }, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .padding(all = 16.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = errorMessage) Spacer(modifier = Modifier.height(4.dp)) Row { FilledTonalButton(onClick = onTry) { Text(text = stringResource(id = R.string.retry)) } FilledTonalButton( onClick = onSecondaryClick, modifier = Modifier.padding(start = 8.dp) ) { Text(text = secondaryText) } } } } } @Composable fun ErrorScreen( errorMessage: String, onTry: () -> Unit, needSecondaryText: Boolean, secondaryText: String, onSecondaryClick: () -> Unit = { }, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .padding(all = 16.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = errorMessage) Spacer(modifier = Modifier.height(4.dp)) Row { FilledTonalButton(onClick = onTry) { Text(text = stringResource(id = R.string.retry)) } if (needSecondaryText) { FilledTonalButton( onClick = onSecondaryClick, modifier = Modifier.padding(start = 8.dp) ) { Text(text = secondaryText) } } } } } } fun LazyGridScope.pagingLoadingIndication(loadState: LoadState, onTry: () -> Unit) { item( span = { GridItemSpan(3) } ) { when (loadState) { is LoadState.Loading -> { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } } is LoadState.NotLoading -> { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Text( text = stringResource(id = R.string.all_clear), modifier = Modifier.padding(16.dp) ) } } is LoadState.Error -> { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .padding(all = 16.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = stringResource(id = R.string.load_failure)) FilledTonalButton(onClick = onTry) { Text(text = stringResource(id = R.string.retry)) } } } } } } } fun LazyListScope.pagingLoadingIndication(loadState: LoadState, onTry: () -> Unit) { item( contentType = "pagingLoadingIndication", key = "pagingLoadingIndication" ) { when (loadState) { is LoadState.Loading -> { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } } is LoadState.NotLoading -> { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Text( text = stringResource(id = R.string.all_clear), modifier = Modifier.padding(16.dp) ) } } is LoadState.Error -> { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .padding(all = 16.dp) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = stringResource(id = R.string.load_failure)) FilledTonalButton(onClick = onTry) { Text(text = stringResource(id = R.string.retry)) } } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/MangaCover.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage enum class MangaCover(val size: Dp) { /** * 最小的封面,大小为65dp */ ExtraSmall(65.dp), /** * 小的封面,大小为100dp */ Small(100.dp), /** * 大的封面,大小为160dp */ Big(160.dp); @Composable operator fun invoke( url: Any?, shape: Shape? = MaterialTheme.shapes.extraSmall, ) { AsyncImage( model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .width(size) .aspectRatio(2f / 3f) .then( if (shape != null) { Modifier.clip(shape) } else { Modifier } ), placeholder = ColorPainter(MaterialTheme.colorScheme.outline) ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/RefreshLayout.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshState import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp @OptIn(ExperimentalMaterialApi::class) @Composable fun RefreshLayout( modifier: Modifier = Modifier, pullRefreshState: PullRefreshState, isRefreshing: Boolean, topPadding: Dp, content: @Composable () -> Unit, ) { Box( modifier = modifier .pullRefresh(state = pullRefreshState) .fillMaxSize() ) { content() PullRefreshIndicator( refreshing = isRefreshing, state = pullRefreshState, contentColor = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(top = topPadding) .align(Alignment.TopCenter) ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/SaveStatePager.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerScope import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.SaveableStateHolder import androidx.compose.ui.Modifier import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughOut private const val TabFadeDuration = 200 /** * 可以保存状态的[HorizontalPager],实际上是一个封装。 * * @param pageContent 内容 * @param savableStateHolder 将状态提升到主界面 * @see HorizontalPager */ @OptIn(ExperimentalFoundationApi::class) @Composable fun SaveStatePager( modifier: Modifier = Modifier, pagerState: PagerState, contentPadding: PaddingValues, savableStateHolder: SaveableStateHolder, keys: (() -> List)? = null, pageContent: @Composable (PagerScope.(Int) -> Unit), ) { HorizontalPager( state = pagerState, modifier = modifier, contentPadding = contentPadding, userScrollEnabled = false ) { savableStateHolder.SaveableStateProvider( key = if (keys != null) keys()[it].hashCode() else it, content = { pageContent(it) } ) } } /** * A content which can switchable and have animation named [materialFadeThroughIn] and [materialFadeThroughOut]. * * @param contentPadding A [PaddingValues] that use in content. * @param currentPager A number which pager now showing. * @param savableStateHolder Provide a [SaveableStateHolder] that will use in this function. * @param keys Provide a key list. It will use [currentPager] if null. * @param pageContent Content showing on screen. */ @Composable fun SaveStateContentPager( modifier: Modifier = Modifier, contentPadding: PaddingValues, currentPager: Int, savableStateHolder: SaveableStateHolder, keys: (() -> List)? = null, pageContent: @Composable (Int) -> Unit, ) { AnimatedContent( modifier = modifier.padding(contentPadding), targetState = currentPager, transitionSpec = { materialFadeThroughIn( initialScale = 1f, durationMillis = TabFadeDuration ) togetherWith materialFadeThroughOut( durationMillis = TabFadeDuration ) }, label = "pager_content_move_with_material_fade" ) { savableStateHolder.SaveableStateProvider( key = if (keys != null) keys()[it].hashCode() else it, content = { pageContent(it) } ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/VerticalFastScroller.kt ================================================ package com.shicheeng.copymanga.ui.screen.compoents import android.view.ViewConfiguration import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.systemGestureExclusion import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @Composable fun VerticalFastScroller( listState: LazyListState, thumbColor: Color = MaterialTheme.colorScheme.primary, topContentPadding: Dp = Dp.Hairline, endContentPadding: Dp = Dp.Hairline, content: @Composable () -> Unit, ) { SubcomposeLayout { constraints -> val contentPlaceable = subcompose("content", content).map { it.measure(constraints) } val contentHeight = contentPlaceable.maxByOrNull { it.height }?.height ?: 0 val contentWidth = contentPlaceable.maxByOrNull { it.width }?.width ?: 0 val scrollerPlaceable = subcompose("scroller") { val layoutInfo = listState.layoutInfo val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount if (!showScroller) return@subcompose val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() } var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) } val dragInteractionSource = remember { MutableInteractionSource() } val isThumbDragged by dragInteractionSource.collectIsDraggedAsState() val heightPx = contentHeight.toFloat() - thumbTopPadding - listState.layoutInfo.afterContentPadding val thumbHeightPx = with(LocalDensity.current) { _thumbLength.toPx() } val trackHeightPx = heightPx - thumbHeightPx val scrolled = remember { MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) } // When list scrolled LaunchedEffect(listState.firstVisibleItemScrollOffset) { if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect val scrollOffset = computeScrollOffset(state = listState) val scrollRange = computeScrollRange(state = listState) val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx) thumbOffsetY = trackHeightPx * proportion + thumbTopPadding scrolled.tryEmit(Unit) } // When thumb dragged LaunchedEffect(thumbOffsetY) { if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx val scrollItem = layoutInfo.totalItemsCount * scrollRatio val scrollItemRounded = scrollItem.roundToInt() val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0 val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded) listState.scrollToItem( index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt() ) scrolled.tryEmit(Unit) } // Thumb alpha val alpha = remember { Animatable(0f) } val isThumbVisible = alpha.value > 0f LaunchedEffect(scrolled, alpha) { scrolled.collectLatest { alpha.snapTo(1f) alpha.animateTo(0f, animationSpec = _fadeOutAnimationSpec) } } Box( modifier = Modifier .offset { IntOffset(0, thumbOffsetY.roundToInt()) } .height(_thumbLength) .then( // Exclude thumb from gesture area only when needed if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) { Modifier.systemGestureExclusion() } else Modifier, ) .padding(end = endContentPadding) .width(_thumbThickness) .alpha(alpha = alpha.value) .background(color = thumbColor, shape = _thumbShape) .then( // Recompose opts if (!listState.isScrollInProgress) { Modifier.draggable( interactionSource = dragInteractionSource, orientation = Orientation.Vertical, enabled = isThumbVisible, state = rememberDraggableState { delta -> val newOffsetY = thumbOffsetY + delta thumbOffsetY = newOffsetY.coerceIn( thumbTopPadding, thumbTopPadding + trackHeightPx ) }, ) } else Modifier, ), ) }.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } val scrollerWidth = scrollerPlaceable.maxByOrNull { it.width }?.width ?: 0 layout(contentWidth, contentHeight) { contentPlaceable.forEach { it.placeRelative(0, 0) } scrollerPlaceable.forEach { it.placeRelative(contentWidth - scrollerWidth, 0) } } } } private fun computeScrollOffset(state: LazyListState): Int { if (state.layoutInfo.totalItemsCount == 0) return 0 val visibleItems = state.layoutInfo.visibleItemsInfo val startChild = visibleItems.first() val endChild = visibleItems.last() val minPosition = min(startChild.index, endChild.index) val maxPosition = max(startChild.index, endChild.index) val itemsBefore = minPosition.coerceAtLeast(0) val startDecoratedTop = startChild.top val laidOutArea = abs(endChild.bottom - startDecoratedTop) val itemRange = abs(minPosition - maxPosition) + 1 val avgSizePerRow = laidOutArea.toFloat() / itemRange return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt() } private fun computeScrollRange(state: LazyListState): Int { if (state.layoutInfo.totalItemsCount == 0) return 0 val visibleItems = state.layoutInfo.visibleItemsInfo val startChild = visibleItems.first() val endChild = visibleItems.last() val laidOutArea = endChild.bottom - startChild.top val laidOutRange = abs(startChild.index - endChild.index) + 1 return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt() } private val _thumbLength = 52.dp private val _thumbThickness = 8.dp private val _thumbShape = RoundedCornerShape(_thumbThickness / 2) private val _fadeOutAnimationSpec = tween( durationMillis = ViewConfiguration.getScrollBarFadeDuration(), delayMillis = 2000, ) private val LazyListItemInfo.top: Int get() = offset private val LazyListItemInfo.bottom: Int get() = offset + size ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/CircularProgressPainter.kt ================================================ /* * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp import kotlin.math.min /** * A private class to do all the drawing of SwipeRefreshIndicator, which includes progress spinner * and the arrow. This class is to separate drawing from animation. * Adapted from CircularProgressDrawable. */ internal class CircularProgressPainter : Painter() { var color by mutableStateOf(Color.Unspecified) var alpha by mutableFloatStateOf(1f) var arcRadius by mutableStateOf(0.dp) var strokeWidth by mutableStateOf(5.dp) var arrowEnabled by mutableStateOf(false) var arrowWidth by mutableStateOf(0.dp) var arrowHeight by mutableStateOf(0.dp) var arrowScale by mutableFloatStateOf(1f) private val arrow: Path by lazy { Path().apply { fillType = PathFillType.EvenOdd } } var startTrim by mutableFloatStateOf(0f) var endTrim by mutableFloatStateOf(0f) var rotation by mutableFloatStateOf(0f) override val intrinsicSize: Size get() = Size.Unspecified override fun applyAlpha(alpha: Float): Boolean { this.alpha = alpha return true } override fun DrawScope.onDraw() { rotate(degrees = rotation) { val arcRadius = arcRadius.toPx() + strokeWidth.toPx() / 2f val arcBounds = Rect( size.center.x - arcRadius, size.center.y - arcRadius, size.center.x + arcRadius, size.center.y + arcRadius ) val startAngle = (startTrim + rotation) * 360 val endAngle = (endTrim + rotation) * 360 val sweepAngle = endAngle - startAngle drawArc( color = color, alpha = alpha, startAngle = startAngle, sweepAngle = sweepAngle, useCenter = false, topLeft = arcBounds.topLeft, size = arcBounds.size, style = Stroke( width = strokeWidth.toPx(), cap = StrokeCap.Square ) ) if (arrowEnabled) { drawArrow(startAngle, sweepAngle, arcBounds) } } } private fun DrawScope.drawArrow(startAngle: Float, sweepAngle: Float, bounds: Rect) { arrow.reset() arrow.moveTo(0f, 0f) arrow.lineTo( x = arrowWidth.toPx() * arrowScale, y = 0f ) arrow.lineTo( x = arrowWidth.toPx() * arrowScale / 2, y = arrowHeight.toPx() * arrowScale ) val radius = min(bounds.width, bounds.height) / 2f val inset = arrowWidth.toPx() * arrowScale / 2f arrow.translate( Offset( x = radius + bounds.center.x - inset, y = bounds.center.y + strokeWidth.toPx() / 2f ) ) arrow.close() rotate(degrees = startAngle + sweepAngle) { drawPath( path = arrow, color = color, alpha = alpha ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/Slingshot.kt ================================================ /* * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.pow /** * A utility function that calculates various aspects of 'slingshot' behavior. * Adapted from SwipeRefreshLayout#moveSpinner method. * * TODO: Investigate replacing this with a spring. * * @param offsetY The current y offset. * @param maxOffsetY The max y offset. * @param height The height of the item to slingshot. */ @Composable internal fun rememberUpdatedSlingshot( offsetY: Float, maxOffsetY: Float, height: Int, ): Slingshot { val offsetPercent = min(1f, offsetY / maxOffsetY) val adjustedPercent = max(offsetPercent - 0.4f, 0f) * 5 / 3 val extraOffset = abs(offsetY) - maxOffsetY // Can accommodate custom start and slingshot distance here val tensionSlingshotPercent = max( 0f, min(extraOffset, maxOffsetY * 2) / maxOffsetY ) val tensionPercent = ( (tensionSlingshotPercent / 4) - (tensionSlingshotPercent / 4).pow(2) ) * 2 val extraMove = maxOffsetY * tensionPercent * 2 val targetY = height + ((maxOffsetY * offsetPercent) + extraMove).toInt() val offset = targetY - height val strokeStart = adjustedPercent * 0.8f val startTrim = 0f val endTrim = strokeStart.coerceAtMost(MaxProgressArc) val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent * 2) * 0.5f val arrowScale = min(1f, adjustedPercent) return remember { Slingshot() }.apply { this.offset = offset this.startTrim = startTrim this.endTrim = endTrim this.rotation = rotation this.arrowScale = arrowScale } } @Stable internal class Slingshot { var offset: Int by mutableIntStateOf(0) var startTrim: Float by mutableFloatStateOf(0f) var endTrim: Float by mutableFloatStateOf(0f) var rotation: Float by mutableFloatStateOf(0f) var arrowScale: Float by mutableFloatStateOf(0f) } internal const val MaxProgressArc = 0.8f ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/SwipeRefresh.kt ================================================ /* * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh import androidx.compose.animation.core.Animatable import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatorMutex import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.absoluteValue import kotlin.math.roundToInt private const val DragMultiplier = 0.5f /** * Creates a [SwipeRefreshState] that is remembered across compositions. * * Changes to [isRefreshing] will result in the [SwipeRefreshState] being updated. * * @param isRefreshing the value for [SwipeRefreshState.isRefreshing] */ @Composable fun rememberSwipeRefreshState( isRefreshing: Boolean, ): SwipeRefreshState { return remember { SwipeRefreshState( isRefreshing = isRefreshing ) }.apply { this.isRefreshing = isRefreshing } } /** * A state object that can be hoisted to control and observe changes for [SwipeRefresh]. * * In most cases, this will be created via [rememberSwipeRefreshState]. * * @param isRefreshing the initial value for [SwipeRefreshState.isRefreshing] */ @Stable class SwipeRefreshState( isRefreshing: Boolean, ) { private val _indicatorOffset = Animatable(0f) private val mutatorMutex = MutatorMutex() /** * Whether this [SwipeRefreshState] is currently refreshing or not. */ var isRefreshing: Boolean by mutableStateOf(isRefreshing) /** * Whether a swipe/drag is currently in progress. */ var isSwipeInProgress: Boolean by mutableStateOf(false) internal set /** * The current offset for the indicator, in pixels. */ val indicatorOffset: Float get() = _indicatorOffset.value internal suspend fun animateOffsetTo(offset: Float) { mutatorMutex.mutate { _indicatorOffset.animateTo(offset) } } /** * Dispatch scroll delta in pixels from touch events. */ internal suspend fun dispatchScrollDelta(delta: Float) { mutatorMutex.mutate(MutatePriority.UserInput) { _indicatorOffset.snapTo(_indicatorOffset.value + delta) } } } private class SwipeRefreshNestedScrollConnection( private val state: SwipeRefreshState, private val coroutineScope: CoroutineScope, private val onRefresh: () -> Unit, ) : NestedScrollConnection { var enabled: Boolean = false var refreshTrigger: Float = 0f override fun onPreScroll( available: Offset, source: NestedScrollSource, ): Offset = when { // If swiping isn't enabled, return zero !enabled -> Offset.Zero // If we're refreshing, return zero state.isRefreshing -> Offset.Zero // If the user is swiping up, handle it source == NestedScrollSource.Drag && available.y < 0 -> onScroll(available) else -> Offset.Zero } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset = when { // If swiping isn't enabled, return zero !enabled -> Offset.Zero // If we're refreshing, return zero state.isRefreshing -> Offset.Zero // If the user is swiping down and there's y remaining, handle it source == NestedScrollSource.Drag && available.y > 0 -> onScroll(available) else -> Offset.Zero } private fun onScroll(available: Offset): Offset { if (available.y > 0) { state.isSwipeInProgress = true } else if (state.indicatorOffset.roundToInt() == 0) { state.isSwipeInProgress = false } val newOffset = (available.y * DragMultiplier + state.indicatorOffset).coerceAtLeast(0f) val dragConsumed = newOffset - state.indicatorOffset return if (dragConsumed.absoluteValue >= 0.5f) { coroutineScope.launch { state.dispatchScrollDelta(dragConsumed) } // Return the consumed Y Offset(x = 0f, y = dragConsumed / DragMultiplier) } else { Offset.Zero } } override suspend fun onPreFling(available: Velocity): Velocity { // If we're dragging, not currently refreshing and scrolled // past the trigger point, refresh! if (!state.isRefreshing && state.indicatorOffset >= refreshTrigger) { onRefresh() } // Reset the drag in progress state state.isSwipeInProgress = false // Don't consume any velocity, to allow the scrolling layout to fling return Velocity.Zero } } /** * A layout which implements the swipe-to-refresh pattern, allowing the user to refresh content via * a vertical swipe gesture. * * This layout requires its content to be scrollable so that it receives vertical swipe events. * The scrollable content does not need to be a direct descendant though. Layouts such as * [androidx.compose.foundation.lazy.LazyColumn] are automatically scrollable, but others such as * [androidx.compose.foundation.layout.Column] require you to provide the * [androidx.compose.foundation.verticalScroll] modifier to that content. * * Apps should provide a [onRefresh] block to be notified each time a swipe to refresh gesture * is completed. That block is responsible for updating the [state] as appropriately, * typically by setting [SwipeRefreshState.isRefreshing] to `true` once a 'refresh' has been * started. Once a refresh has completed, the app should then set * [SwipeRefreshState.isRefreshing] to `false`. * * If an app wishes to show the progress animation outside of a swipe gesture, it can * set [SwipeRefreshState.isRefreshing] as required. * * This layout does not clip any of it's contents, including the indicator. If clipping * is required, apps can provide the [androidx.compose.ui.draw.clipToBounds] modifier. * * @sample com.google.accompanist.sample.swiperefresh.SwipeRefreshSample * * @param state the state object to be used to control or observe the [SwipeRefresh] state. * @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed. * @param modifier the modifier to apply to this layout. * @param swipeEnabled Whether the the layout should react to swipe gestures or not. * @param refreshTriggerDistance The minimum swipe distance which would trigger a refresh. * @param indicatorAlignment The alignment of the indicator. Defaults to [Alignment.TopCenter]. * @param indicatorPadding Content padding for the indicator, to inset the indicator in if required. * @param indicator the indicator that represents the current state. By default this * will use a [SwipeRefreshIndicator]. * @param clipIndicatorToPadding Whether to clip the indicator to [indicatorPadding]. If false is * provided the indicator will be clipped to the [content] bounds. Defaults to true. * @param content The content containing a scroll composable. */ @Composable fun SwipeRefresh( state: SwipeRefreshState, onRefresh: () -> Unit, modifier: Modifier = Modifier, swipeEnabled: Boolean = true, refreshTriggerDistance: Dp = 80.dp, indicatorAlignment: Alignment = Alignment.TopCenter, indicatorPadding: PaddingValues = PaddingValues(0.dp), indicator: @Composable (state: SwipeRefreshState, refreshTrigger: Dp) -> Unit = { s, trigger -> SwipeRefreshIndicator(s, trigger) }, clipIndicatorToPadding: Boolean = true, content: @Composable () -> Unit, ) { val coroutineScope = rememberCoroutineScope() val updatedOnRefresh = rememberUpdatedState(onRefresh) // Our LaunchedEffect, which animates the indicator to its resting position LaunchedEffect(state.isSwipeInProgress) { if (!state.isSwipeInProgress) { // If there's not a swipe in progress, rest the indicator at 0f state.animateOffsetTo(0f) } } val refreshTriggerPx = with(LocalDensity.current) { refreshTriggerDistance.toPx() } // Our nested scroll connection, which updates our state. val nestedScrollConnection = remember(state, coroutineScope) { SwipeRefreshNestedScrollConnection(state, coroutineScope) { // On refresh, re-dispatch to the update onRefresh block updatedOnRefresh.value.invoke() } }.apply { this.enabled = swipeEnabled this.refreshTrigger = refreshTriggerPx } Box(modifier.nestedScroll(connection = nestedScrollConnection)) { content() Box( Modifier // If we're not clipping to the padding, we use clipToBounds() before the padding() // modifier. .let { if (!clipIndicatorToPadding) it.clipToBounds() else it } .padding(indicatorPadding) .matchParentSize() // Else, if we're are clipping to the padding, we use clipToBounds() after // the padding() modifier. .let { if (clipIndicatorToPadding) it.clipToBounds() else it } ) { Box(Modifier.align(indicatorAlignment)) { indicator(state, refreshTriggerDistance) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/compoents/pullrefresh/SwipeRefreshIndicator.kt ================================================ /* * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.shicheeng.copymanga.ui.screen.compoents.pullrefresh import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.animate import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** * A class to encapsulate details of different indicator sizes. * * @param size The overall size of the indicator. * @param arcRadius The radius of the arc. * @param strokeWidth The width of the arc stroke. * @param arrowWidth The width of the arrow. * @param arrowHeight The height of the arrow. */ @Immutable private data class SwipeRefreshIndicatorSizes( val size: Dp, val arcRadius: Dp, val strokeWidth: Dp, val arrowWidth: Dp, val arrowHeight: Dp, ) /** * The default/normal size values for [SwipeRefreshIndicator]. */ private val DefaultSizes = SwipeRefreshIndicatorSizes( size = 40.dp, arcRadius = 7.5.dp, strokeWidth = 2.5.dp, arrowWidth = 10.dp, arrowHeight = 5.dp, ) /** * The 'large' size values for [SwipeRefreshIndicator]. */ private val LargeSizes = SwipeRefreshIndicatorSizes( size = 56.dp, arcRadius = 11.dp, strokeWidth = 3.dp, arrowWidth = 12.dp, arrowHeight = 6.dp, ) /** * Indicator composable which is typically used in conjunction with [SwipeRefresh]. * * @param state The [SwipeRefreshState] passed into the [SwipeRefresh] `indicator` block. * @param modifier The modifier to apply to this layout. * @param fade Whether the arrow should fade in/out as it is scrolled in. Defaults to true. * @param scale Whether the indicator should scale up/down as it is scrolled in. Defaults to false. * @param arrowEnabled Whether an arrow should be drawn on the indicator. Defaults to true. * @param backgroundColor The color of the indicator background surface. * @param contentColor The color for the indicator's contents. * @param shape The shape of the indicator background surface. Defaults to [CircleShape]. * @param largeIndication Whether the indicator should be 'large' or not. Defaults to false. * @param elevation The size of the shadow below the indicator. */ @Composable fun SwipeRefreshIndicator( state: SwipeRefreshState, refreshTriggerDistance: Dp, modifier: Modifier = Modifier, fade: Boolean = true, scale: Boolean = false, arrowEnabled: Boolean = true, backgroundColor: Color = MaterialTheme.colorScheme.primary, contentColor: Color = contentColorFor(backgroundColor), shape: Shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)), refreshingOffset: Dp = 16.dp, largeIndication: Boolean = false, elevation: Dp = 6.dp, ) { val sizes = if (largeIndication) LargeSizes else DefaultSizes val indicatorRefreshTrigger = with(LocalDensity.current) { refreshTriggerDistance.toPx() } val indicatorHeight = with(LocalDensity.current) { sizes.size.roundToPx() } val refreshingOffsetPx = with(LocalDensity.current) { refreshingOffset.toPx() } val slingshot = rememberUpdatedSlingshot( offsetY = state.indicatorOffset, maxOffsetY = indicatorRefreshTrigger, height = indicatorHeight, ) var offset by remember { mutableFloatStateOf(0f) } if (state.isSwipeInProgress) { // If the user is currently swiping, we use the 'slingshot' offset directly offset = slingshot.offset.toFloat() } else { // If there's no swipe currently in progress, animate to the correct resting position LaunchedEffect(state.isRefreshing) { animate( initialValue = offset, targetValue = when { state.isRefreshing -> indicatorHeight + refreshingOffsetPx else -> 0f } ) { value, _ -> offset = value } } } val adjustedElevation = when { state.isRefreshing -> elevation offset > 0.5f -> elevation else -> 0.dp } Surface( modifier = modifier .size(size = sizes.size) .graphicsLayer { // Translate the indicator according to the slingshot translationY = offset - indicatorHeight val scaleFraction = if (scale && !state.isRefreshing) { val progress = offset / indicatorRefreshTrigger.coerceAtLeast(1f) // We use LinearOutSlowInEasing to speed up the scale in LinearOutSlowInEasing .transform(progress) .coerceIn(0f, 1f) } else 1f scaleX = scaleFraction scaleY = scaleFraction }, shape = shape, color = backgroundColor, tonalElevation = adjustedElevation, shadowElevation = adjustedElevation ) { val painter = remember { CircularProgressPainter() } painter.arcRadius = sizes.arcRadius painter.strokeWidth = sizes.strokeWidth painter.arrowWidth = sizes.arrowWidth painter.arrowHeight = sizes.arrowHeight painter.arrowEnabled = arrowEnabled && !state.isRefreshing painter.color = contentColor val alpha = if (fade) { (state.indicatorOffset / indicatorRefreshTrigger).coerceIn(0f, 1f) } else { 1f } painter.alpha = alpha painter.startTrim = slingshot.startTrim painter.endTrim = slingshot.endTrim painter.rotation = slingshot.rotation painter.arrowScale = slingshot.arrowScale // This shows either an Image with CircularProgressPainter or a CircularProgressIndicator, // depending on refresh state Crossfade( targetState = state.isRefreshing, animationSpec = tween(durationMillis = CrossfadeDurationMs), label = "Cross Fade Refresh" ) { refreshing -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { if (refreshing) { val circleSize = (sizes.arcRadius + sizes.strokeWidth) * 2 CircularProgressIndicator( color = contentColor, strokeWidth = sizes.strokeWidth, modifier = Modifier.size(circleSize), ) } else { Image( painter = painter, contentDescription = "Refreshing" ) } } } } } private const val CrossfadeDurationMs = 100 ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/DownloadScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.download import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.work.WorkInfo import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadScreen( viewModel: DownloadScreenViewModel = hiltViewModel(), onCardClick: (String) -> Unit, onNavigationClick: () -> Unit, ) { val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val items by viewModel.items.collectAsState() Scaffold( topBar = { LargeTopAppBar( title = { Text(text = stringResource(id = R.string.download_manga)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNavigationClick ) } ) }, modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { paddingValues -> if (items != null) { LazyColumn(contentPadding = paddingValues) { items?.forEach { (t, u) -> item { Text( text = stringResource( id = when (t) { WorkInfo.State.ENQUEUED -> R.string.waiting WorkInfo.State.RUNNING -> R.string.downloading WorkInfo.State.SUCCEEDED -> R.string.completed WorkInfo.State.FAILED -> R.string.failure_download WorkInfo.State.BLOCKED -> R.string.prerequisites_miss WorkInfo.State.CANCELLED -> R.string.cancel } ), modifier = Modifier.padding(16.dp) ) } items(u) { DownloadItem( downloadUiDataModel = it, onCancel = { viewModel.cancel(it.id) }, onCardClick = { onCardClick(it.pathWord) } ) { if (it.isPause) { viewModel.resume(it.id) } else { viewModel.pause(it.id) } } } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/DownloadScreenComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.download import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.work.WorkInfo import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.downloadmodel.DownloadUiDataModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadItem( downloadUiDataModel: DownloadUiDataModel, onCancel: () -> Unit, onCardClick: () -> Unit, onClick: () -> Unit, ) { val animatedProgressState = ProgressIndicatorDefaults.ProgressAnimationSpec val progressAnimated by remember { mutableFloatStateOf(0f) } val progressAnimatedAsState by animateFloatAsState( targetValue = progressAnimated, animationSpec = animatedProgressState, label = "progress_animated" ) OutlinedCard( onClick = { onCardClick() }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) { Row( modifier = Modifier .fillMaxWidth() .padding(8.dp) ) { AsyncImage( model = downloadUiDataModel.localSavableMangaModel.mangaHistoryDataModel.url, contentDescription = null, modifier = Modifier .height(120.dp) .clip(MaterialTheme.shapes.medium) .aspectRatio(2f / 3f), contentScale = ContentScale.Crop ) Column( modifier = Modifier .weight(1f) .padding(start = 16.dp, end = 12.dp) ) { Text( text = downloadUiDataModel.localSavableMangaModel.mangaHistoryDataModel.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) Text( text = "%.2f%%".format(downloadUiDataModel.percent * 100), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) if (downloadUiDataModel.workerState == WorkInfo.State.RUNNING) { Text( text = downloadUiDataModel.getEtaString()?.toString() ?: stringResource(R.string.downloading), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) StateProgressIndication( isIndeterminate = downloadUiDataModel.isIndeterminate, progress = progressAnimatedAsState, modifier = Modifier.padding(top = 8.dp) ) } } when (downloadUiDataModel.workerState) { WorkInfo.State.RUNNING -> { StateButton( isPause = downloadUiDataModel.isPause, modifier = Modifier .padding(end = 16.dp) .align(Alignment.CenterVertically), onClick = onClick ) } WorkInfo.State.ENQUEUED -> { StateButton( id = R.drawable.baseline_close_24, contentDescription = stringResource(R.string.cancel), onClick = onCancel, modifier = Modifier .padding(end = 16.dp) .align(Alignment.CenterVertically) ) } else -> {} } } } } @Composable private fun StateProgressIndication( modifier: Modifier = Modifier, isIndeterminate: Boolean, progress: Float, ) { Box( modifier = modifier, contentAlignment = Alignment.CenterStart ) { if (!isIndeterminate) { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .clip(CircleShape), progress = progress ) } else { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .clip(CircleShape), ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/DownloadScreenViewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.download import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.Data import androidx.work.WorkInfo import com.shicheeng.copymanga.data.downloadmodel.DownloadUiDataModel import com.shicheeng.copymanga.data.local.LocalSavableMangaModel import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.server.download.domin.DownloadState import com.shicheeng.copymanga.server.download.woker.DownloadedWorker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.UUID import javax.inject.Inject @HiltViewModel class DownloadScreenViewModel @Inject constructor( private val downloaderWorker: DownloadedWorker.Caller, private val mangaHistoryRepository: MangaHistoryRepository, ) : ViewModel() { private val mangaCache = LinkedHashMap() private val mutexCache = Mutex() @OptIn(ExperimentalCoroutinesApi::class) private val workerData = downloaderWorker.observerWorker() .mapLatest { it.mapToUiDataModel() }.stateIn( scope = viewModelScope + Dispatchers.Default, initialValue = null, started = SharingStarted.Eagerly ) val items = workerData.map { dataModels -> dataModels?.groupBy { it.workerState } }.stateIn( scope = viewModelScope, initialValue = emptyMap(), started = SharingStarted.Eagerly ) fun resume(id: UUID) { val snapshot = workerData.value ?: return for (work in snapshot) { if (id == work.id) { downloaderWorker.resume(id) } } } fun cancel(id: UUID) = viewModelScope.launch { val snapshot = workerData.value ?: return@launch for (work in snapshot) { if (id == work.id) { downloaderWorker.cancel(id) } } } fun pause(id: UUID) { val snapshot = workerData.value ?: return for (work in snapshot) { if (id == work.id) { downloaderWorker.pause(id) } } } private suspend fun List.mapToUiDataModel(): List { if (isEmpty()) { return emptyList() } val list = mapNotNullTo(ArrayList(size)) { it.toWorkUiDataModel() } list.sortedByDescending { it.timeStamp } return list } private suspend fun WorkInfo.toWorkUiDataModel(): DownloadUiDataModel? { val data = if (outputData == Data.EMPTY) progress else outputData val pathWord = (DownloadState getMangaPathWord data) ?: return null val manga = getManga(pathWord) ?: return null return DownloadUiDataModel( pathWord = pathWord, workerState = state, localSavableMangaModel = manga, isStopped = DownloadState isStoppedIn data, isPause = DownloadState isPauseIn data, isIndeterminate = DownloadState indeterminateFor data, max = DownloadState getMax data, totalChapter = DownloadState.downloadChaptersIn(data).size, error = DownloadState getError data, progress = DownloadState getProgress data, timeStamp = DownloadState timeStampWhich data, id = id, eta = DownloadState timeETAIn data ) } private suspend fun getManga(pathWord: String): LocalSavableMangaModel? { mangaCache[pathWord]?.let { return it } return mutexCache.withLock { mangaCache.getOrElse(pathWord) { mangaHistoryRepository.getMangaByPathWord(pathWord) ?.also { mangaCache[pathWord] = it } ?: return null } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/EmptyScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.download import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R @Composable fun EmptyScreen( paddingValues: PaddingValues = PaddingValues(0.dp), @StringRes id: Int = R.string.empty_download, ) { Box( modifier = Modifier .fillMaxSize() .padding(paddingValues), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.undraw_no_data_re_kwbl), contentDescription = null, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(id = id), fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurface ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/download/StateButton.kt ================================================ package com.shicheeng.copymanga.ui.screen.download import androidx.annotation.DrawableRes import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.theme.ElevationTokens @Composable fun StateButton( modifier: Modifier = Modifier, size: Dp = 48.dp, isPause: Boolean, onClick: () -> Unit, ) { val interaction = remember { MutableInteractionSource() } val isPressed by interaction.collectIsPressedAsState() val cornerSize by animateDpAsState( label = "state_button_size", targetValue = if (isPressed || isPressed) { size } else { 12.dp } ) Surface( onClick = onClick, tonalElevation = ElevationTokens.Level3, shape = RoundedCornerShape(cornerSize), interactionSource = interaction, modifier = modifier.size(size) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( painter = painterResource( id = if (isPause) { R.drawable.baseline_play_arrow_24 } else { R.drawable.baseline_pause_24 } ), contentDescription = stringResource(id = if (isPause) R.string.resume else R.string.pause) ) } } } /** * 圆角可变换的按钮 * @param size 大小 * @param id 图片资源id * @param contentDescription 辅助服务使用的文本来描述该图标所代表的含义。 应始终提供此图标,除非该图标用于装饰目的,并且不代表用户可以采取的有意义的操作。 该文本应该本地化,例如使用 [androidx.compose.ui.res.stringResource] 或类似。 * @param onClick 点击事件回调 */ @Composable fun StateButton( modifier: Modifier = Modifier, size: Dp = 48.dp, @DrawableRes id: Int, contentDescription: String?, onClick: () -> Unit, ) { val interaction = remember { MutableInteractionSource() } val isPressed by interaction.collectIsPressedAsState() val cornerSize by animateDpAsState( label = "state_button_size", targetValue = if (isPressed || isPressed) { size } else { 12.dp } ) Surface( onClick = onClick, tonalElevation = ElevationTokens.Level3, shape = RoundedCornerShape(cornerSize), interactionSource = interaction, modifier = modifier.size(size) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(id = id), contentDescription = contentDescription ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/downloaded/Downloaded.kt ================================================ package com.shicheeng.copymanga.ui.screen.downloaded import android.net.Uri import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.PersonalInnerDataModel import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.download.EmptyScreen import com.shicheeng.copymanga.util.copyComposable import com.shicheeng.copymanga.viewmodel.DownloadViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadedScreen( cViewModel: DownloadViewModel = hiltViewModel(), onNavigate: () -> Unit, onClick: (String?) -> Unit, ) { val list by cViewModel.list.collectAsState() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(R.string.download_manga)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNavigate ) } ) } ) { paddingValues -> if (list.isEmpty()) { EmptyScreen(paddingValues = paddingValues) } else { LazyVerticalGrid( contentPadding = paddingValues.copyComposable( start = 16.dp, end = 16.dp ), columns = GridCells.Fixed(3), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { items(list) { innerItem: PersonalInnerDataModel? -> innerItem?.let { DownloadedListItem(url = innerItem.url, title = innerItem.name) { if (innerItem.pathWord != null) { onClick(innerItem.pathWord) } } } } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadedListItem( url: Uri?, title: String, onClick: () -> Unit, ) { Card( modifier = Modifier.width(IntrinsicSize.Min), onClick = onClick ) { Column( modifier = Modifier.padding(bottom = 4.dp) ) { AsyncImage( model = url, contentDescription = null, placeholder = ColorPainter(MaterialTheme.colorScheme.primary), modifier = Modifier .clip(MaterialTheme.shapes.medium) .aspectRatio(2f / 3f), contentScale = ContentScale.Crop ) Text( text = title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(4.dp) ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/downloaded/DownloadedResolveDialog.kt ================================================ package com.shicheeng.copymanga.ui.screen.downloaded import androidx.compose.runtime.Composable import androidx.compose.ui.window.Dialog @Composable fun DownloadedResolveDialog() = Dialog(onDismissRequest = { /*TODO*/ }) { } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/error/ErrorScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.error import android.content.Context import android.content.Intent import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.FilledTonalButton 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R @Composable fun ErrorScreen( message: String?, onFinishClick: () -> Unit, ) { val scrollState = rememberScrollState() val context = LocalContext.current Surface( modifier = Modifier.fillMaxSize() ) { Column( modifier = Modifier .fillMaxSize() .padding(all = 16.dp), ) { Text( text = stringResource(R.string.fatal_error), style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface ) Text( text = stringResource(R.string.try_to_send_to_me), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) Text( text = message ?: stringResource(id = R.string.no_content), modifier = Modifier .weight(1f) .scrollable( state = scrollState, orientation = Orientation.Vertical ) ) FilledTonalButton( onClick = { context.composeEmail( addresses = arrayOf("sshizzhi1234@gmail.com"), body = message ?: "" ) }, modifier = Modifier.align(Alignment.End) ) { Text(text = stringResource(R.string.send)) } FilledTonalButton( onClick = onFinishClick, modifier = Modifier.align(Alignment.End) ) { Text(text = stringResource(R.string.finish_this)) } } } } fun Context.composeEmail(addresses: Array, body: String) { val intent = Intent(Intent.ACTION_SEND).apply { type = "*/*" putExtra(Intent.EXTRA_EMAIL, addresses) putExtra(Intent.EXTRA_TEXT, body) } startActivity(intent) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/history/History.kt ================================================ package com.shicheeng.copymanga.ui.screen.history import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.SaveStatePager import com.shicheeng.copymanga.ui.screen.compoents.withAppBarColor import com.shicheeng.copymanga.ui.screen.history.local.LocalHistoryScreen import com.shicheeng.copymanga.ui.screen.history.web.WebHistoryScreen import com.shicheeng.copymanga.util.copyComposable import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HistoryScreen( navigationClick: () -> Unit, onRequestLogin: () -> Unit, onPathWord: (String) -> Unit, ) { val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val stringIds = rememberHistoryWord() val pagerState = rememberPagerState(pageCount = stringIds::size) val savableState = rememberSaveableStateHolder() val coroutineScope = rememberCoroutineScope() Scaffold( topBar = { Column( modifier = Modifier.fillMaxWidth() ) { TopAppBar( title = { Text(text = stringResource(id = R.string.history)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = navigationClick ) } ) PrimaryTabRow( selectedTabIndex = pagerState.currentPage, containerColor = withAppBarColor(topAppBarState = topAppBarScrollBehavior.state), ) { for (i in 0 until pagerState.pageCount) { val interactionSource = remember(::MutableInteractionSource) Tab( selected = pagerState.currentPage == i, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(i) } }, text = { Text(text = stringResource(id = stringIds[i])) }, modifier = Modifier, interactionSource = interactionSource ) } } } } ) { paddingValues -> SaveStatePager( pagerState = pagerState, contentPadding = paddingValues.copyComposable( bottom = 0.dp ), savableStateHolder = savableState, keys = { stringIds }, modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { index -> when (index) { 0 -> { LocalHistoryScreen( onPathWord = onPathWord, bottomPadding = paddingValues.calculateBottomPadding() ) } 1 -> { WebHistoryScreen( onPathWord = onPathWord, onRequestLogin = onRequestLogin, bottomPadding = paddingValues.calculateBottomPadding() ) } } } } } @Composable private fun rememberHistoryWord() = remember { listOf( R.string.local_history, R.string.web_history ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/history/local/HistoryComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.history.local 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.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.webhistory.WebHistoryItem import com.shicheeng.copymanga.ui.screen.compoents.CommonCover import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.util.convertToOnlyTime @Composable fun HistoryItem( modifier: Modifier = Modifier, data: MangaHistoryDataModel, onClick: () -> Unit, onDeleteClick: () -> Unit, ) { Row( modifier = modifier .clickable(onClick = onClick) .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Card( modifier = Modifier.width(50.dp), shape = RoundedCornerShape(4.dp) ) { CommonCover( url = data.url, contentDescription = data.name, shape = RoundedCornerShape(size = 4.dp) ) } Column( modifier = Modifier .padding(start = 8.dp) .weight(1f) ) { Text( text = data.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( text = "${data.time.convertToOnlyTime()} • ${ stringResource( id = R.string.info_read_in, formatArgs = arrayOf(data.positionChapter + 1, data.positionPage + 1) ) }", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) } PlainButton( id = R.string.delete, drawableRes = R.drawable.baseline_delete_outline_24, onButtonClick = onDeleteClick ) } } @Composable fun HistoryItemCloud( modifier: Modifier = Modifier, data: WebHistoryItem, onClick: () -> Unit, ) { Row( modifier = modifier .clickable(onClick = onClick) .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Card( modifier = Modifier.width(50.dp), shape = RoundedCornerShape(4.dp) ) { CommonCover( url = data.comic.cover, contentDescription = data.comic.name, shape = RoundedCornerShape(size = 4.dp) ) } Column( modifier = Modifier .padding(start = 8.dp) .weight(1f) ) { Text( text = data.comic.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( text = data.lastChapterName, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 2.dp, end = 8.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) } PlainButton( id = R.string.shelf_cloud, drawableRes = R.drawable.outline_cloud_24 ) {} } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/history/local/LocalHistoryScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.history.local import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.download.EmptyScreen import com.shicheeng.copymanga.util.convertToTimeGroup import com.shicheeng.copymanga.viewmodel.HistoryViewModel @OptIn(ExperimentalFoundationApi::class) @Composable fun LocalHistoryScreen( historyViewModel: HistoryViewModel = hiltViewModel(), bottomPadding: Dp, onPathWord: (String) -> Unit, ) { val historyList by historyViewModel.historyList.collectAsState() val historyGrouped = historyList .filter { it.positionChapter != 0 || it.positionPage != 0 } .groupBy { it.time.convertToTimeGroup() } Box( modifier = Modifier.fillMaxSize() ) { if (historyList.isEmpty()) { EmptyScreen(id = R.string.no_history) } else { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = bottomPadding, top = 16.dp) ) { historyGrouped.forEach { stringListEntry -> if (stringListEntry.value.isNotEmpty()) { item( key = stringListEntry.hashCode(), contentType = "HEADER" ) { Text( text = stringListEntry.key, modifier = Modifier .padding(horizontal = 16.dp, vertical = 4.dp) .animateItemPlacement() ) } } items( stringListEntry.value, key = { "history-${it.hashCode()}" }, contentType = { "item" } ) { historyItem -> HistoryItem( data = historyItem, onClick = { onPathWord(historyItem.pathWord) }, modifier = Modifier.animateItemPlacement() ) { historyViewModel.deleteHistory(historyItem) } } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/history/web/WebHistoryScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.history.web import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.RefreshLayout import com.shicheeng.copymanga.ui.screen.history.local.HistoryItemCloud import retrofit2.HttpException @OptIn(ExperimentalMaterialApi::class) @Composable fun WebHistoryScreen( viewModel: WebHistoryViewModel = hiltViewModel(), bottomPadding: Dp, onPathWord: (String) -> Unit, onRequestLogin: () -> Unit, ) { val list = viewModel.list.collectAsLazyPagingItems() val refreshState = rememberPullRefreshState( refreshing = list.loadState.refresh is LoadState.Loading, onRefresh = { list.refresh() } ) if (list.loadState.refresh is LoadState.Loading) { LoadingScreen() return } if (list.loadState.refresh is LoadState.Error) { val error = (list.loadState.refresh as LoadState.Error).error ErrorScreen( errorMessage = error.message ?: "", onTry = { list.refresh() }, needSecondaryText = error is HttpException && error.code() == 401, secondaryText = stringResource(id = R.string.re_login), onSecondaryClick = onRequestLogin ) return } RefreshLayout( pullRefreshState = refreshState, isRefreshing = list.loadState.refresh is LoadState.Loading, topPadding = 0.dp ) { LazyColumn( contentPadding = PaddingValues(bottom = bottomPadding, top = 16.dp) ) { items(list.itemCount) { index -> list[index]?.let { item -> HistoryItemCloud(data = item) { onPathWord(item.comic.pathWord) } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/history/web/WebHistoryViewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.history.web import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.shicheeng.copymanga.resposity.WebHistoryRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class WebHistoryViewModel @Inject constructor( webHistoryRepository: WebHistoryRepository, ) : ViewModel() { val list = webHistoryRepository.historyOnWeb().cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/list/CommonListComponent.kt ================================================ package com.shicheeng.copymanga.ui.screen.list import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api 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.alpha import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.ui.screen.compoents.CommonCover @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommonListItem( url: String, title: String, author: String, onClick: () -> Unit, ) { Card( modifier = Modifier.width(IntrinsicSize.Min), onClick = onClick ) { Column( modifier = Modifier.padding(bottom = 4.dp) ) { CommonCover(url = url, contentDescription = title) Text( text = title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = author, style = MaterialTheme.typography.bodySmall, modifier = Modifier.alpha(0.78f), overflow = TextOverflow.Ellipsis, maxLines = 1 ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/list/NewestScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.viewmodel.MangaNewestListViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun NewestScreen( recommendViewModel: MangaNewestListViewModel = hiltViewModel(), onBack: () -> Unit, onPathWord: (String) -> Unit, ) { val list = recommendViewModel.list.collectAsLazyPagingItems() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val layoutDirection = LocalLayoutDirection.current Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.new_manga)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) } ) } ) { paddingValues -> LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = paddingValues.copy( layoutDirection = layoutDirection, bottom = paddingValues.calculateBottomPadding(), start = 16.dp, end = 16.dp ), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(list.itemCount) { itemIndex -> list[itemIndex]?.let { item -> CommonListItem( url = item.comic.cover, title = item.comic.name, author = item.comic.authorReformation() ) { onPathWord.invoke(item.comic.pathWord) } } } pagingLoadingIndication( loadState = list.loadState.append ) { list.retry() } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/list/RecommendScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.viewmodel.MangaRecommendListViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun RecommendScreen( recommendViewModel: MangaRecommendListViewModel = hiltViewModel(), onBack: () -> Unit, onPathWord: (String) -> Unit, ) { val list = recommendViewModel.recommendMangaList.collectAsLazyPagingItems() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val layoutDirection = LocalLayoutDirection.current Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.recommend)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) } ) } ) { paddingValues -> LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = paddingValues.copy( layoutDirection = layoutDirection, bottom = paddingValues.calculateBottomPadding(), start = 16.dp, end = 16.dp ), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(list.itemCount) { itemIndex -> list[itemIndex]?.let { item -> CommonListItem( url = item.comic.cover, title = item.comic.name, author = item.comic.authorReformation() ) { onPathWord.invoke(item.comic.pathWord) } } } pagingLoadingIndication( loadState = list.loadState.append, onTry = list::retry ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/login/LoginScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.login import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.CircleLoadingButton import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.util.LoginState @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( viewModel: LoginViewModel = hiltViewModel(), onNavClick: () -> Unit, onLoadingSuccess: () -> Unit, ) { val (username, onUsername) = rememberSaveable { mutableStateOf("") } val (password, onPassword) = rememberSaveable { mutableStateOf("") } val loginState by viewModel.loginStatus.collectAsState() var isPasswordError by remember { mutableStateOf(false) } var isUsernameError by remember { mutableStateOf(false) } var isPasswordVisible by remember { mutableStateOf(false) } LaunchedEffect(key1 = loginState) { if (loginState is LoginState.Success) { onLoadingSuccess() } } Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(R.string.login_text)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNavClick ) } ) } ) { Column( modifier = Modifier .fillMaxSize() .padding(it), horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.undraw_login_re), contentDescription = null, modifier = Modifier .size(200.dp) .clip(MaterialTheme.shapes.large), ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = username, onValueChange = onUsername, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), label = { Text(text = stringResource(R.string.user_name_text)) }, singleLine = true, isError = isUsernameError ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = password, onValueChange = onPassword, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), label = { Text(text = stringResource(R.string.password_text)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, ), singleLine = true, isError = loginState is LoginState.Error<*> || isPasswordError, visualTransformation = if (!isPasswordVisible) { PasswordVisualTransformation() } else { VisualTransformation.None }, trailingIcon = { PlainButton( id = { R.string.password_text }, drawableRes = { if (isPasswordVisible) { R.drawable.baseline_visibility_off_24 } else { R.drawable.baseline_visibility_24 } } ) { isPasswordVisible = !isPasswordVisible } } ) Spacer(modifier = Modifier.height(16.dp)) CircleLoadingButton( isLoading = loginState == LoginState.Loading, onClick = { when { username.isEmptyOrBlank() -> isUsernameError = true password.isEmptyOrBlank() -> isPasswordError = true password.isEmptyOrBlank() && username.isEmptyOrBlank() -> { isPasswordError = true isUsernameError = true } else -> { viewModel.loginUP(username, password) } } } ) } } } private fun String.isEmptyOrBlank() = this.isEmpty() || this.isBlank() ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/login/LoginViewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.resposity.LoginRepository import com.shicheeng.copymanga.util.LoginState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val repository: LoginRepository, ) : ViewModel() { private val _username = MutableStateFlow("") private val _password = MutableStateFlow("") @OptIn(ExperimentalCoroutinesApi::class) val loginStatus = combine(_username, _password) { u, p -> UPPackage(u, p) }.filter { it.notEmptyOrBlank() }.flatMapLatest { repository.login(username = it.username, password = it.password) }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = LoginState.NoStatus ) fun loginUP(username: String, password: String) = viewModelScope.launch { _username.emit(username) _password.emit(password) } data class UPPackage( val username: String, val password: String, ) { fun notEmptyOrBlank(): Boolean { return username.isNotBlank() && password.isNotBlank() && username.isNotEmpty() && password.isNotEmpty() } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/login/loginlist/LoginPeronsalItem.kt ================================================ package com.shicheeng.copymanga.ui.screen.login.loginlist import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.interaction.MutableInteractionSource 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.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.login.LocalLoginDataModel import com.shicheeng.copymanga.ui.screen.main.personal.hostFor @Composable fun LoginPersonalSelection( modifier: Modifier = Modifier, isSelected: Boolean, localLoginDataModel: LocalLoginDataModel, onDelete: () -> Unit, onClick: () -> Unit, ) { val mutableInteraction = remember(::MutableInteractionSource) Row( modifier = modifier .fillMaxWidth() .selectable( selected = isSelected, onClick = onClick, indication = LocalIndication.current, interactionSource = mutableInteraction ) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton( selected = isSelected, onClick = onClick, interactionSource = mutableInteraction ) AsyncImage( model = hostFor(localLoginDataModel.avatarImageUrl), contentDescription = null, modifier = Modifier .size(48.dp) .clip(CircleShape), placeholder = ColorPainter(color = MaterialTheme.colorScheme.primary) ) Column( modifier = Modifier .weight(1f) .padding(start = 16.dp) ) { Text( text = localLoginDataModel.nikeName, style = MaterialTheme.typography.titleMedium ) Text( text = localLoginDataModel.userName, style = MaterialTheme.typography.titleSmall ) } IconButton(onClick = onDelete) { Icon( painter = painterResource(id = R.drawable.baseline_delete_24), contentDescription = stringResource(id = R.string.delete) ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/login/loginlist/LoginPersonListScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.login.loginlist import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.viewmodel.LoginPersonalListViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginPersonalListScreen( viewModel: LoginPersonalListViewModel = hiltViewModel(), onAddClicked: () -> Unit, navigationClick: () -> Unit, ) { val list by viewModel.personalList.collectAsState() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(R.string.login_personal)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = navigationClick ) } ) }, floatingActionButton = { FloatingActionButton(onClick = onAddClicked) { Icon( painter = painterResource(id = R.drawable.baseline_add_24), contentDescription = stringResource(R.string.add) ) } } ) { paddingValues -> if (list.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.undraw_no_data_re_kwbl), contentDescription = null, ) Text(text = stringResource(id = R.string.no_login)) } } } else { LazyColumn( contentPadding = paddingValues, modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { items(list) { LoginPersonalSelection( isSelected = it.selected, localLoginDataModel = it, onDelete = { viewModel.delete(it) } ) { viewModel.selectUUId(it.userID) } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/MainScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.main import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.Router import com.shicheeng.copymanga.ui.screen.compoents.SaveStateContentPager import com.shicheeng.copymanga.ui.screen.main.explore.ExploreScreen import com.shicheeng.copymanga.ui.screen.main.home.HomeScreen import com.shicheeng.copymanga.ui.screen.main.leaderboard.LeaderBoardScreen import com.shicheeng.copymanga.ui.screen.main.personal.PersonalScreen import com.shicheeng.copymanga.viewmodel.MainViewModel import kotlinx.coroutines.launch import retrofit2.HttpException @Composable fun MainScreen( modifier: Modifier = Modifier, mainViewModel: MainViewModel = hiltViewModel(), onUUid: (String) -> Unit, onDownloadedBtnClick: () -> Unit, onSearchButtonClick: () -> Unit, onSettingButtonClick: () -> Unit, onRecommendHeaderLineClick: () -> Unit, onNewestHeaderLineClick: () -> Unit, onSubscribedClick: () -> Unit, onHistoryClick: () -> Unit, onLibraryClick: () -> Unit, onPersonalHeaderClick: (isLogin: Boolean) -> Unit, onTopicClick: (pathWord: String, type: Int) -> Unit, onTopicHeaderLineClick: () -> Unit, onFinishHeaderLineClick: () -> Unit, onLoginExpireClick: () -> Unit, onHotClick: () -> Unit, ) { val screens = listOf( Router.HOME, Router.LEADERBOARD, Router.EXPLORE, Router.PERSONAL ) val corScope = rememberCoroutineScope() val savableStateHolder = rememberSaveableStateHolder() var selectIndex by rememberSaveable { mutableIntStateOf(0) } val loginStatus by mainViewModel.loginInfoStatus.collectAsState() val showSnack by mainViewModel.showSnackBar.collectAsState() val snackStateHost = remember(::SnackbarHostState) val localContext = LocalContext.current LaunchedEffect(key1 = loginStatus) { if (loginStatus != null && showSnack) { if (loginStatus is HttpException && (loginStatus as HttpException).code() == 401) { snackStateHost.showSnackbar( message = localContext.getString(R.string.login_expired), actionLabel = localContext.getString(R.string.re_login), duration = SnackbarDuration.Short ).also { if (it == SnackbarResult.ActionPerformed) { onLoginExpireClick() } if (it == SnackbarResult.Dismissed) { mainViewModel.dismissShack() } } mainViewModel.dismissShack() } else { snackStateHost.showSnackbar( message = localContext.getString(R.string.login_failure), withDismissAction = true, duration = SnackbarDuration.Short ) mainViewModel.dismissShack() } } } Scaffold( bottomBar = { NavigationBar { screens.forEach { screen -> NavigationBarItem( selected = selectIndex == screens.indexOf(screen), onClick = { if ( (selectIndex == screens.indexOf(screen)) && (screen.name == Router.PERSONAL.name) ) { onSettingButtonClick() } else { selectIndex = screens.indexOf(screen) } }, label = { Text(text = stringResource(id = screen.stringId!!)) }, icon = { Icon( painter = painterResource( id = if (selectIndex == screens.indexOf(screen)) { screen.onClickIcon!! } else { screen.drawableRes!! } ), contentDescription = null ) } ) } } }, modifier = modifier, contentWindowInsets = WindowInsets(top = 0), snackbarHost = { SnackbarHost(hostState = snackStateHost) { Snackbar(snackbarData = it) } }, ) { paddingValues -> SaveStateContentPager( contentPadding = paddingValues, savableStateHolder = savableStateHolder, currentPager = selectIndex ) { index -> when (index) { 0 -> { HomeScreen( onUUid = onUUid, onSearchButtonClick = onSearchButtonClick, onSettingButtonClick = onSettingButtonClick, onRecommendHeaderLineClick = onRecommendHeaderLineClick, onRankHeaderLineClick = { corScope.launch { selectIndex = 1 } }, onHotHeaderLineClick = onHotClick, onNewestHeaderLineClick = onNewestHeaderLineClick, onFinishHeaderLineClick = onFinishHeaderLineClick, onTopicsClickLineClick = onTopicHeaderLineClick, onTopicCardClick = { onTopicClick(it.pathWord, it.type) } ) } 1 -> { LeaderBoardScreen { onUUid(it.comic.pathWord) } } 2 -> { ExploreScreen( top = null, theme = null, order = null, ) { onUUid(it.pathWord) } } 3 -> { PersonalScreen( onHistoryClick = onHistoryClick, onLibraryClick = onLibraryClick, onDownloadClick = onDownloadedBtnClick, onSubscribedClick = onSubscribedClick, onPersonalHeaderClick = onPersonalHeaderClick ) { onSettingButtonClick() } } } } } BackHandler(enabled = selectIndex != 0) { selectIndex = 0 } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/MainScreenViewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.main import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject @HiltViewModel class MainScreenViewModel @Inject constructor() : ViewModel() { private val _topWord = MutableStateFlow(null) private val _orderWord = MutableStateFlow(null) private val _themeWord = MutableStateFlow(null) val order = _orderWord.asStateFlow() val top = _topWord.asStateFlow() val theme = _themeWord.asStateFlow() fun addOrder(orderPathWord: String) { _orderWord.tryEmit(orderPathWord) _topWord.tryEmit(null) } fun addTop(topWord: String) { _topWord.tryEmit(topWord) _orderWord.tryEmit(null) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/explore/ExploreComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.explore import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api 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.alpha import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.ui.screen.compoents.CommonCover @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExploreItem( modifier: Modifier = Modifier, item: Item, onItemClick: (Item) -> Unit, ) { Card( modifier = modifier.width(IntrinsicSize.Min), onClick = { onItemClick.invoke(item) } ) { Column( modifier = Modifier .padding(bottom = 4.dp) .wrapContentSize() ) { CommonCover( url = item.cover, contentDescription = item.name ) Text( text = item.name, style = MaterialTheme.typography.titleSmall, overflow = TextOverflow.Ellipsis, maxLines = 1 ) Text( text = item.authorReformation(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.alpha(0.78f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/explore/ExploreFilterBottomSheet.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.explore import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandHorizontally import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.json.MangaSortJson import com.shicheeng.copymanga.ui.screen.compoents.dimensionAttribute @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExploreFilterBottomSheet( title: String, list: List, sortBean: MangaSortBean?, onSelected: (MangaSortBean) -> Unit, ) { Scaffold( modifier = Modifier.fillMaxWidth(), topBar = { Column { TopAppBar( title = { Text(text = title) } ) HorizontalDivider() } } ) { LazyColumn( modifier = Modifier .padding(24.dp), contentPadding = it ) { itemsIndexed(list) { index, item -> ExploreFilterBottomSheetItem( msb = item, isSelected = sortBean?.pathWord == item.pathWord, onSelected = onSelected ) } } } } @Composable private fun ExploreFilterBottomSheetItem( modifier: Modifier = Modifier, msb: MangaSortBean, isSelected: Boolean, onSelected: (MangaSortBean) -> Unit, ) { Column( modifier = modifier .clip(shape = CircleShape) .selectable( isSelected, onClick = { onSelected(msb) }, role = Role.Button ) .background( if (isSelected) MaterialTheme.colorScheme.primary.copy(.22f) else Color.Transparent, ) .fillMaxWidth() ) { Row( modifier = Modifier .height(height = 56.dp) .padding( start = dimensionAttribute(attrResId = android.R.attr.listPreferredItemPaddingStart), end = dimensionAttribute(attrResId = android.R.attr.listPreferredItemPaddingEnd) ), verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility( visible = isSelected, enter = expandHorizontally(expandFrom = Alignment.Start), exit = shrinkHorizontally(shrinkTowards = Alignment.Start) ) { Icon( painter = painterResource(id = R.drawable.baseline_done_24), contentDescription = null, modifier = Modifier .padding(end = 16.dp) ) Spacer(modifier = Modifier.width(width = dimensionAttribute(attrResId = android.R.attr.listPreferredItemPaddingStart))) } Text( text = msb.pathName, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleSmall ) } } } private fun roundedCornerShapeWith(isLast: Boolean, isTop: Boolean, size: Dp): RoundedCornerShape { return when { isTop -> RoundedCornerShape( topStart = size, topEnd = size, bottomEnd = 0.dp, bottomStart = 0.dp ) isLast -> RoundedCornerShape( topStart = 0.dp, topEnd = 0.dp, bottomEnd = size, bottomStart = size ) else -> RoundedCornerShape(0.dp) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/explore/ExploreMangaFilter.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.explore import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.json.MangaSortJson @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExploreFilter( modifier: Modifier = Modifier, showList: MutableMap, onThemeClick: () -> Unit, onOrderClick: () -> Unit, onTopClick: () -> Unit, ) { Column( modifier = modifier .fillMaxWidth() .animateContentSize(), ) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { FilterChip( onClick = onOrderClick, modifier = Modifier.animateContentSize(), label = { Text( text = showList[MangaSortJson.ORDER]?.pathName ?: stringResource(id = R.string.order) ) }, selected = showList[MangaSortJson.ORDER] != null, trailingIcon = { Icon( painter = painterResource(id = com.google.android.material.R.drawable.mtrl_ic_arrow_drop_down), contentDescription = null ) } ) FilterChip( onClick = onTopClick, modifier = Modifier.animateContentSize(), label = { Text( text = showList[MangaSortJson.PATH]?.pathName ?: stringResource(id = R.string.top) ) }, trailingIcon = { Icon( painter = painterResource(id = com.google.android.material.R.drawable.mtrl_ic_arrow_drop_down), contentDescription = null ) }, selected = showList[MangaSortJson.PATH] != null ) FilterChip( onClick = onThemeClick, modifier = Modifier.animateContentSize(), label = { Text( text = showList[MangaSortJson.THEME]?.pathName ?: stringResource(id = R.string.theme) ) }, trailingIcon = { Icon( painter = painterResource(id = com.google.android.material.R.drawable.mtrl_ic_arrow_drop_down), contentDescription = null ) }, selected = showList[MangaSortJson.THEME] != null ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/explore/ExploreScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.explore import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.json.MangaSortJson import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.SwipeRefresh import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.rememberSwipeRefreshState import com.shicheeng.copymanga.ui.screen.compoents.withAppBarColor import com.shicheeng.copymanga.ui.theme.ElevationTokens import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.viewmodel.ExploreMangaViewModel @OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class ) @Composable fun ExploreScreen( top: String?, theme: String?, order: String?, exploreMangaViewModel: ExploreMangaViewModel = hiltViewModel(), onNavigationIconClick: (() -> Unit)? = null, onItemClick: (Item) -> Unit, ) { val sortLoadingState by exploreMangaViewModel.uiState.collectAsState() val mangaList = exploreMangaViewModel.loadFilterResult.collectAsLazyPagingItems() val bottomWhatToShow by exploreMangaViewModel.showBottomSheet.collectAsState() val coroutineScope = rememberCoroutineScope() if (sortLoadingState is UIState.Loading) { LoadingScreen() return } if (sortLoadingState is UIState.Error<*>) { ErrorScreen( errorMessage = (sortLoadingState as UIState.Error<*>).errorMessage.message ?: stringResource(id = R.string.error) ) { exploreMangaViewModel.loadData() } return } val successData = sortLoadingState as UIState.Success if (top != null || order != null || theme != null) { LaunchedEffect(Unit) { exploreMangaViewModel.filterOn( order, theme, top ) } } var isExpand by remember { mutableStateOf(false) } val layoutDirection = LocalLayoutDirection.current val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = false ) val pullRefreshState = rememberSwipeRefreshState( isRefreshing = mangaList.loadState.refresh is LoadState.Loading, ) val (orderSave, onOrderSave) = rememberSaveable { mutableStateOf(null) } val (topSave, onTopSave) = rememberSaveable { mutableStateOf(null) } val (themeSave, onThemeSave) = rememberSaveable { mutableStateOf(null) } val hashMap = remember { mutableStateMapOf( MangaSortJson.ORDER to MangaSortJson.order.find { x -> x.pathWord == orderSave || x.pathWord == order }, MangaSortJson.THEME to successData.content.find { x -> x.pathWord == themeSave || x.pathWord == theme }, MangaSortJson.PATH to MangaSortJson.topPath.find { x -> x.pathWord == topSave || x.pathWord == top }, ) } val topAppBarState = rememberTopAppBarState() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(state = topAppBarState) Scaffold( topBar = { Column { TopAppBar( title = { Text(text = stringResource(id = R.string.explore)) }, navigationIcon = { if (onNavigationIconClick != null) { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNavigationIconClick ) } }, scrollBehavior = topAppBarScrollBehavior ) ExploreFilter( showList = hashMap, onThemeClick = { exploreMangaViewModel.showThemeFilterList() isExpand = true }, onOrderClick = { exploreMangaViewModel.showOrderFilterList() isExpand = true }, modifier = Modifier .background(withAppBarColor(topAppBarState = topAppBarState)) .padding(horizontal = 16.dp) ) { exploreMangaViewModel.showTopFilterList() isExpand = true } HorizontalDivider() } }, ) { paddingValues -> SwipeRefresh( modifier = Modifier.fillMaxSize(), state = pullRefreshState, onRefresh = mangaList::refresh, indicatorPadding = paddingValues ) { LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = paddingValues.copy( layoutDirection = layoutDirection, bottom = paddingValues.calculateBottomPadding(), start = 16.dp, end = 16.dp, top = paddingValues.calculateTopPadding() + 16.dp ), modifier = Modifier .fillMaxSize() .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { items( mangaList.itemCount, key = mangaList.itemKey(), contentType = mangaList.itemContentType() ) { itemIndex -> mangaList[itemIndex]?.let { item -> ExploreItem( item = item, onItemClick = onItemClick, modifier = Modifier.animateItemPlacement() ) } } pagingLoadingIndication( loadState = mangaList.loadState.append ) { mangaList.retry() } } } } val cornerShape by animateDpAsState( targetValue = if (sheetState.currentValue == SheetValue.Expanded) { 0.dp } else { 28.0.dp }, label = "DP_OF_SHEET" ) if (isExpand) { ModalBottomSheet( onDismissRequest = { isExpand = false }, sheetState = sheetState, windowInsets = WindowInsets(0, 0, 0, 0), modifier = Modifier.zIndex(1f), shape = RoundedCornerShape( topEnd = cornerShape, topStart = cornerShape, bottomStart = 0.dp, bottomEnd = 0.dp ), tonalElevation = ElevationTokens.Level2 ) { when (bottomWhatToShow) { MangaSortJson.ORDER -> { ExploreFilterBottomSheet( list = MangaSortJson.order, sortBean = hashMap[MangaSortJson.ORDER], title = stringResource(id = R.string.order) ) { hashMap[MangaSortJson.ORDER] = it exploreMangaViewModel.filterOn( theme = hashMap[MangaSortJson.THEME]?.pathWord, top = hashMap[MangaSortJson.PATH]?.pathWord, order = it.pathWord.ifBlank { null } ) onOrderSave(it.pathWord) } } MangaSortJson.THEME -> { ExploreFilterBottomSheet( list = successData.content, sortBean = hashMap[MangaSortJson.THEME], title = stringResource(id = R.string.theme) ) { hashMap[MangaSortJson.THEME] = it exploreMangaViewModel.filterOn( theme = it.pathWord.ifBlank { null }, top = hashMap[MangaSortJson.PATH]?.pathWord, order = hashMap[MangaSortJson.ORDER]?.pathWord ) onThemeSave(it.pathWord) } } MangaSortJson.PATH -> { ExploreFilterBottomSheet( list = MangaSortJson.topPath, sortBean = hashMap[MangaSortJson.PATH], title = stringResource(id = R.string.top), onSelected = { hashMap[MangaSortJson.PATH] = it exploreMangaViewModel.filterOn( theme = hashMap[MangaSortJson.THEME]?.pathWord, top = it.pathWord.ifBlank { null }, order = hashMap[MangaSortJson.ORDER]?.pathWord ) onTopSave(it.pathWord) } ) } } } } BackHandler(enabled = isExpand) { isExpand = false } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/BannerComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.google.accompanist.pager.HorizontalPagerIndicator import com.shicheeng.copymanga.data.DataBannerBean import com.shicheeng.copymanga.util.click import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.yield /** * 按照官方的例子抄的 */ @OptIn(ExperimentalFoundationApi::class) @Composable fun Banner( modifier: Modifier = Modifier, list: List, click: (DataBannerBean) -> Unit, ) { val pageCount = list.size val pageStateBanner = rememberBannerState(initialCount = 0, pagerCount = { pageCount }) fun pageMapper(index: Int): Int = (index - 0) floorMod pageCount var underDragging by remember { mutableStateOf(false) } LaunchedEffect(key1 = Unit) { pageStateBanner.interactionSource.interactions.collect { interaction -> when (interaction) { is PressInteraction.Press -> underDragging = true is PressInteraction.Release -> underDragging = false is PressInteraction.Cancel -> underDragging = false is DragInteraction.Start -> underDragging = true is DragInteraction.Stop -> underDragging = false is DragInteraction.Cancel -> underDragging = false } } } Box( modifier = modifier.fillMaxWidth() ) { HorizontalPager( state = pageStateBanner, modifier = Modifier .fillMaxWidth() .wrapContentHeight() ) { index -> val page = pageMapper(index) BannerItem(dataBannerBean = list[page], bannerClick = click) } HorizontalPagerIndicator( pagerState = pageStateBanner, pageCount = pageCount, modifier = Modifier.align(Alignment.BottomCenter), pageIndexMapping = ::pageMapper ) } AutoScrollSideEffect( autoScrollDurationMillis = 5000L, pageCount = pageCount, pagerState = pageStateBanner, doAutoScroll = underDragging.not() ) } @Composable fun BannerItem( dataBannerBean: DataBannerBean, bannerClick: (DataBannerBean) -> Unit, ) { val colors = listOf( MaterialTheme.colorScheme.surface, Color.Transparent, Color.Transparent, Color.Transparent, MaterialTheme.colorScheme.surface ) val textBackgroundColor = MaterialTheme.colorScheme.surface Box( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .click { bannerClick.invoke(dataBannerBean) } ) { AsyncImage( model = dataBannerBean.bannerImageUrl, contentDescription = null, contentScale = ContentScale.Crop, placeholder = ColorPainter(MaterialTheme.colorScheme.surface), modifier = Modifier .matchParentSize() .drawWithContent { drawContent() drawRect(brush = Brush.verticalGradient(colors)) } ) Column( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomStart) .padding(bottom = 16.dp, start = 16.dp, end = 16.dp) ) { Text( text = dataBannerBean.bannerBrief, modifier = Modifier .drawWithContent { drawRect(color = textBackgroundColor) drawContent() } .padding(2.dp), color = MaterialTheme.colorScheme.onSurface ) } } } /** * 使用新的自动滑动方法。 */ @Composable private fun AutoScrollSideEffect( autoScrollDurationMillis: Long, pageCount: Int, pagerState: BannerState, doAutoScroll: Boolean, onAutoScrollChange: (isAutoScrollActive: Boolean) -> Unit = {}, ) { if (autoScrollDurationMillis == Long.MAX_VALUE || autoScrollDurationMillis < 0) { return } // Needed to ensure that the code within LaunchedEffect receives updates to the itemCount. val updatedItemCount by rememberUpdatedState(newValue = pageCount) if (doAutoScroll) { LaunchedEffect(pagerState) { while (true) { yield() delay(autoScrollDurationMillis) if (pagerState.activePauseHandlesCount > 0) { snapshotFlow { pagerState.activePauseHandlesCount } .first { pauseHandleCount -> pauseHandleCount == 0 } } pagerState.moveToNextItem(updatedItemCount) } } } onAutoScrollChange(doAutoScroll) } private infix fun Int.floorMod(other: Int): Int = when (other) { 0 -> this else -> this - floorDiv(other) * other } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/BannerState.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.pager.PagerState 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.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import java.lang.Math.floorMod @OptIn(ExperimentalFoundationApi::class) class BannerState( initialActiveItemIndex: Int = 0, updatedPageCount: () -> Int, ) : PagerState() { internal var activePauseHandlesCount by mutableIntStateOf(0) /** * The index of the item that is currently displayed by the carousel */ var activeItemIndex by mutableIntStateOf(initialActiveItemIndex) internal set var pagerCountState = mutableStateOf(updatedPageCount) internal var isMovingBackward = false private set override val pageCount: Int get() = pagerCountState.value.invoke() fun moveToPreviousItem(itemCount: Int) { // No items available for carousel if (itemCount == 0) return isMovingBackward = true // Go to previous item activeItemIndex = floorMod(activeItemIndex - 1, itemCount) } internal suspend fun moveToNextItem(itemCount: Int) { // No items available for carousel if (itemCount == 0) return isMovingBackward = false // Go to next item activeItemIndex = floorMod(activeItemIndex + 1, itemCount) animateScrollToPage(activeItemIndex) } companion object { /** * The default [Saver] implementation for [Banner]. */ val Saver: Saver = Saver( save = { it.activeItemIndex } ) { BannerState { it } } } } @Composable fun rememberBannerState( initialCount: Int, pagerCount: () -> Int, ) = rememberSaveable(saver = BannerState.Saver) { BannerState( updatedPageCount = pagerCount, initialActiveItemIndex = initialCount ).apply { pagerCountState.value = pagerCount } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/HomeComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home import androidx.compose.foundation.layout.Arrangement 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.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme 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.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.ListBeanManga import com.shicheeng.copymanga.ui.screen.compoents.MangaCover @Composable fun HomeBarColumn( title: String, list: List, onHeaderLineClick: () -> Unit, onEachClick: (ListBeanManga) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() ) { HomeRowHeaderLine(title = title, click = onHeaderLineClick) LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(all = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { items(list) { HomeBarColumnCover(listBeanManga = it, click = onEachClick) } } } } @Composable fun HomeRowHeaderLine( title: String, click: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f) ) TextButton( modifier = Modifier, onClick = click, ) { Text( text = stringResource(R.string.see_all), modifier = Modifier.align(Alignment.CenterVertically) ) } } } @Preview(showBackground = true) @Composable fun HomeRowHeaderLinePriview() { HomeRowHeaderLine(title = "haokande") { } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeBarColumnCover( listBeanManga: ListBeanManga, click: (ListBeanManga) -> Unit, ) { Card( onClick = { click.invoke(listBeanManga) } ) { Column( modifier = Modifier .width(IntrinsicSize.Min) .padding(bottom = 4.dp) ) { MangaCover.Big( url = listBeanManga.urlCoverManga, shape = MaterialTheme.shapes.medium ) Text( text = listBeanManga.nameManga, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = listBeanManga.authorManga, style = MaterialTheme.typography.bodySmall, maxLines = 1, color = MaterialTheme.colorScheme.onSurfaceVariant, overflow = TextOverflow.Ellipsis ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/HomeLeaderBoard.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home import androidx.compose.foundation.ExperimentalFoundationApi 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.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MainPageDataModel import com.shicheeng.copymanga.data.MangaRankMiniModel import com.shicheeng.copymanga.ui.screen.compoents.MangaCover @OptIn(ExperimentalFoundationApi::class) fun LazyListScope.miniLeaderBoard( selectedTabIndex: Int, onTabClick: (Int) -> Unit, rankList: List, mainPageDataModel: MainPageDataModel, onHeaderLineClick: () -> Unit, onRankItemClick: (MangaRankMiniModel) -> Unit, ) { item( key = HomeListKey.RANK, contentType = HomeListKey.RANK ) { HomeRowHeaderLine( title = stringResource(id = R.string.rank_mini), click = onHeaderLineClick ) } stickyHeader { TabRow(selectedTabIndex = selectedTabIndex) { rankList.forEachIndexed { index, s -> Tab( selected = index == selectedTabIndex, onClick = { onTabClick.invoke(index) }, text = { Text(text = s) } ) } } } when (selectedTabIndex) { 0 -> { items(mainPageDataModel.listRankDay, key = { it.name }) { MiniRankItem( miniModel = it, onRankItemClick = onRankItemClick, modifier = Modifier.animateItemPlacement() ) } } 1 -> { items( items = mainPageDataModel.listRankWeek, key = { it.name } ) { MiniRankItem( miniModel = it, onRankItemClick = onRankItemClick, modifier = Modifier.animateItemPlacement() ) } } 2 -> { items( items = mainPageDataModel.listRankMonth, key = { it.name } ) { MiniRankItem( miniModel = it, onRankItemClick = onRankItemClick, modifier = Modifier.animateItemPlacement() ) } } } } @Composable fun MiniRankItem( modifier: Modifier = Modifier, miniModel: MangaRankMiniModel, onRankItemClick: (MangaRankMiniModel) -> Unit, ) { Row( modifier = modifier .clickable { onRankItemClick.invoke(miniModel) } .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) ) { MangaCover.ExtraSmall( url = miniModel.urlCover, ) Column( modifier = Modifier .padding(start = 8.dp, end = 8.dp) .weight(1f) ) { Text( text = miniModel.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) Text( text = miniModel.author, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp) ) Row { Icon( painter = painterResource(id = R.drawable.ic_trend_up), contentDescription = stringResource(R.string.trend_up), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary ) Text( text = miniModel.riseHot, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 4.dp) ) } Text( text = miniModel.popular, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 2.dp) ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/HomeListKey.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home enum class HomeListKey { BANNER, RANK, RECOMMEND, HOT, NEWEST, FINISH, TOPICS_RECOMMEND } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/HomeScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha 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 com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MainTopicDataModel import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.viewmodel.HomeViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), onUUid: (String) -> Unit, onSearchButtonClick: () -> Unit, onSettingButtonClick: () -> Unit, onRecommendHeaderLineClick: () -> Unit, onRankHeaderLineClick: () -> Unit, onHotHeaderLineClick: () -> Unit, onNewestHeaderLineClick: () -> Unit, onFinishHeaderLineClick: () -> Unit, onTopicCardClick: (MainTopicDataModel) -> Unit, onTopicsClickLineClick: () -> Unit, ) { val uiState by homeViewModel.uiState.collectAsState() if (uiState is UIState.Loading) { LoadingScreen() return } if (uiState is UIState.Error<*>) { ErrorScreen( errorMessage = (uiState as UIState.Error<*>).errorMessage.message ?: stringResource(id = R.string.error), secondaryText = stringResource(id = R.string.setting), onTry = { homeViewModel.loadData() }, onSecondaryClick = onSettingButtonClick ) return } val successUIState = uiState as UIState.Success val layoutDirection = LocalLayoutDirection.current val listRank = listOf( stringResource(id = R.string.day_rank), stringResource(id = R.string.week_rank), stringResource(id = R.string.month_rank) ) var selectTabIndex by remember { mutableIntStateOf(0) } val lazyListState = rememberLazyListState() Scaffold( modifier = Modifier.fillMaxSize(), topBar = { val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } val firstVisibleItemScrollOffset by remember { derivedStateOf { lazyListState.firstVisibleItemScrollOffset } } val animatedTitleAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0) 1f else 0f, label = "animated_title_alpha", ) val animatedBgAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, label = "animated_background_alpha", ) TopAppBar( title = { Text( text = stringResource(id = R.string.app_name), modifier = Modifier.alpha(animatedTitleAlpha) ) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) .copy(alpha = animatedBgAlpha) ), actions = { PlainButton( id = androidx.appcompat.R.string.search_menu_title, drawableRes = R.drawable.ic_manga_search, onButtonClick = onSearchButtonClick ) PlainButton( id = R.string.setting, drawableRes = R.drawable.ic_setting_outline, onButtonClick = onSettingButtonClick ) } ) } ) { padding -> LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = padding.copy( layoutDirection = layoutDirection, bottom = padding.calculateBottomPadding() , top = 0.dp ), state = lazyListState ) { item( key = HomeListKey.BANNER, contentType = HomeListKey.BANNER ) { Banner( list = successUIState.content.listBanner, modifier = Modifier.height(250.dp) ) { model -> onUUid.invoke(model.uuidManga) } } item( key = HomeListKey.RECOMMEND, contentType = HomeListKey.RECOMMEND ) { HomeBarColumn( title = stringResource(id = R.string.recommend), list = successUIState.content.listRecommend, onHeaderLineClick = onRecommendHeaderLineClick ) { onUUid.invoke(it.pathWordManga) } } miniLeaderBoard( selectedTabIndex = selectTabIndex, onTabClick = { index -> selectTabIndex = index }, rankList = listRank, mainPageDataModel = successUIState.content, onHeaderLineClick = onRankHeaderLineClick ) { onUUid.invoke(it.pathWord) } item( key = HomeListKey.HOT, contentType = HomeListKey.HOT ) { HomeBarColumn( title = stringResource(id = R.string.hot_manga), list = successUIState.content.listHot, onHeaderLineClick = onHotHeaderLineClick ) { onUUid.invoke(it.pathWordManga) } } item( key = HomeListKey.NEWEST, contentType = HomeListKey.NEWEST ) { HomeBarColumn( title = stringResource(id = R.string.new_manga), list = successUIState.content.listNewest, onHeaderLineClick = onNewestHeaderLineClick ) { onUUid.invoke(it.pathWordManga) } } item( key = HomeListKey.FINISH, contentType = HomeListKey.FINISH ) { HomeBarColumn( title = stringResource(id = R.string.finish_manga), list = successUIState.content.listFinished, onHeaderLineClick = onFinishHeaderLineClick ) { onUUid.invoke(it.pathWordManga) } } item( key = HomeListKey.TOPICS_RECOMMEND, contentType = HomeListKey.TOPICS_RECOMMEND ) { HomePageTopicRow( list = successUIState.content.topicList, onTopicBarClick = onTopicsClickLineClick, onItemClick = onTopicCardClick ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/HomeTopicCard.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard 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.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MainTopicDataModel @Composable fun HomePageTopicRow( list: List, onTopicBarClick: () -> Unit, onItemClick: (MainTopicDataModel) -> Unit, ) { val lazyState = rememberLazyListState() Column { HomeRowHeaderLine(title = stringResource(R.string.topic), click = onTopicBarClick) LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(all = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), state = lazyState ) { items(list) { HomePageTopicCard( title = it.name, supportedText = it.brief, subText = it.period, imageUrl = it.coverUrl, modifier = Modifier.width(320.dp), maxSupportedTextLine = 3, isTitleMaxTwo = true ) { onItemClick(it) } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomePageTopicCard( modifier: Modifier = Modifier, title: String, supportedText: String, subText: String, imageUrl: String?, maxSupportedTextLine: Int = Int.MAX_VALUE, isTitleMaxTwo: Boolean = false, onClickAction: () -> Unit, ) { OutlinedCard( modifier = modifier, onClick = onClickAction ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { Text( text = title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = if (isTitleMaxTwo) 2 else Int.MAX_VALUE, overflow = TextOverflow.Ellipsis ) Text( text = subText, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 16.dp) ) Text( text = supportedText, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), maxLines = maxSupportedTextLine, overflow = TextOverflow.Ellipsis ) AsyncImage( model = imageUrl, contentDescription = null, placeholder = ColorPainter(color = MaterialTheme.colorScheme.secondary), modifier = Modifier .wrapContentHeight() .fillMaxWidth() .padding(top = 16.dp, bottom = 16.dp) .clip(MaterialTheme.shapes.medium) .aspectRatio(5f / 2f), contentScale = ContentScale.Crop ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/search/Search.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.util.copyComposable import com.shicheeng.copymanga.viewmodel.SearchViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun SearchScreen( searchViewModel: SearchViewModel = hiltViewModel(), onSearch: (String) -> Unit, onBack: () -> Unit, ) { val (searchKeyWord, onSaveKeyWord) = rememberSaveable { mutableStateOf("") } val historyWords by searchViewModel.searchedHistoryWord.collectAsState() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( topBar = { FullScreenSearchViewHeader( value = searchKeyWord, valueChange = { onSaveKeyWord(it) searchViewModel.upWord(it) }, onSearch = { onSearch(it) searchViewModel.saveSearchWord(it) }, onBackClick = onBack, topAppBarScrollBehavior = topAppBarScrollBehavior ) { onSaveKeyWord("") searchViewModel.upWord("") } } ) { paddingValue -> LazyColumn( modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), contentPadding = paddingValue.copyComposable( top = 16.dp + paddingValue.calculateTopPadding() ) ) { items( items = historyWords, key = { it } ) { ListItem( headlineContent = { Text(text = it) }, leadingContent = { Icon( painter = painterResource(id = R.drawable.baseline_history_24), contentDescription = null ) }, modifier = Modifier .animateItemPlacement() .clickable { onSearch(it) searchViewModel.saveSearchWord(it) } ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/home/search/SearchComponent.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.home.search import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import soup.compose.material.motion.MaterialFade @OptIn(ExperimentalMaterial3Api::class) @Composable fun FullScreenSearchViewHeader( modifier: Modifier = Modifier, topAppBarScrollBehavior: TopAppBarScrollBehavior, value: String, valueChange: (String) -> Unit, onSearch: (String) -> Unit, onBackClick: () -> Unit, onClearClick: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() ) { TopAppBar( modifier = modifier.fillMaxWidth(), title = { BasicTextField( value = value, onValueChange = valueChange, singleLine = true, modifier = Modifier, textStyle = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface ), keyboardActions = KeyboardActions( onSearch = { onSearch(value) } ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), cursorBrush = SolidColor(value = MaterialTheme.colorScheme.secondary) ) { innerTextField -> Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart ) { innerTextField() MaterialFade(visible = value.isBlank()) { Text( text = stringResource(id = R.string.search_text), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBackClick ) }, actions = { MaterialFade(visible = value.isNotEmpty()) { PlainButton( id = com.google.android.material.R.string.clear_text_end_icon_content_description, drawableRes = R.drawable.baseline_close_24, onButtonClick = onClearClick ) } }, scrollBehavior = topAppBarScrollBehavior ) HorizontalDivider(color = MaterialTheme.colorScheme.outline) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/leaderboard/LeaderBoard.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.leaderboard import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.rank.Item import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.ui.screen.compoents.withAppBarColor import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.viewmodel.RankViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LeaderBoardScreen( rankViewModel: RankViewModel = hiltViewModel(), onRankItemClick: (Item) -> Unit, ) { val day = rankViewModel.dayRank.collectAsLazyPagingItems() val week = rankViewModel.weekRank.collectAsLazyPagingItems() val month = rankViewModel.monthRank.collectAsLazyPagingItems() val total = rankViewModel.totalRank.collectAsLazyPagingItems() val leaderboardString = listOf( stringResource(id = R.string.day_rank), stringResource(id = R.string.week_rank), stringResource(id = R.string.month_rank), stringResource(id = R.string.all_rank) ) val layoutDirection = LocalLayoutDirection.current val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scope = rememberCoroutineScope() val columns = GridCells.Fixed(3) val pagerState = rememberPagerState( initialPage = 0, initialPageOffsetFraction = 0f, pageCount = leaderboardString::size ) Scaffold( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.comic_rank)) }, scrollBehavior = topAppBarScrollBehavior ) } ) { Column( Modifier .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) .fillMaxSize() .padding(top = it.calculateTopPadding()) ) { TabRow( selectedTabIndex = pagerState.currentPage, modifier = Modifier.fillMaxWidth(), containerColor = withAppBarColor(topAppBarState = topAppBarScrollBehavior.state) ) { leaderboardString.forEachIndexed { index, s -> Tab( selected = pagerState.currentPage == index, onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, text = { Text(text = s) } ) } } HorizontalPager( state = pagerState, ) { index -> when (index) { 0 -> { LeaderBoradPage(day, columns, it, layoutDirection, onRankItemClick) } 1 -> { LeaderBoradPage( day = week, columns = columns, it = it, layoutDirection = layoutDirection, onRankItemClick = onRankItemClick ) } 2 -> { LeaderBoradPage( day = month, columns = columns, it = it, layoutDirection = layoutDirection, onRankItemClick = onRankItemClick ) } 3 -> { LeaderBoradPage( day = total, columns = columns, it = it, layoutDirection = layoutDirection, onRankItemClick = onRankItemClick ) } } } } } } @Composable private fun LeaderBoradPage( day: LazyPagingItems, columns: GridCells.Fixed, it: PaddingValues, layoutDirection: LayoutDirection, onRankItemClick: (Item) -> Unit, ) { when (day.loadState.refresh) { is LoadState.Loading -> { LoadingScreen() } is LoadState.Error -> { ErrorScreen(errorMessage = stringResource(id = R.string.error)) { day.refresh() } } else -> { LazyVerticalGrid( columns = columns, verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), contentPadding = it.copy( layoutDirection = layoutDirection, bottom = it.calculateBottomPadding(), start = 16.dp, end = 16.dp, top = 16.dp ), ) { items(day.itemCount) { itemIndex -> val item = day[itemIndex] if (item != null) { LeaderBoardItem( item = item, onItemClick = onRankItemClick ) } else { LeaderBoardItemPlaceholder() } } pagingLoadingIndication( loadState = day.loadState.append ) { day.retry() } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/leaderboard/LeaderboardComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.leaderboard import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api 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.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.rank.Item import com.shicheeng.copymanga.ui.screen.compoents.CommonCover @OptIn(ExperimentalMaterial3Api::class) @Composable fun LeaderBoardItem( item: Item, onItemClick: (Item) -> Unit, ) { Card( modifier = Modifier.width(IntrinsicSize.Min), onClick = { onItemClick.invoke(item) } ) { Column( modifier = Modifier .padding(bottom = 4.dp) .wrapContentSize() ) { CommonCover( url = item.comic.cover, contentDescription = item.comic.name ) Text( text = item.comic.name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = item.comic.authorThat(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.alpha(0.78f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } @Composable fun LeaderBoardItemPlaceholder() { Card( modifier = Modifier.width(IntrinsicSize.Min), ) { Column( modifier = Modifier .padding(bottom = 4.dp) .wrapContentSize() ) { CommonCover( url = "", contentDescription = "" ) Text( text = stringResource(id = R.string.waiting), style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = stringResource(id = R.string.waiting), style = MaterialTheme.typography.titleSmall, modifier = Modifier.alpha(0.78f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/personal/PersonalHeaderView.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.personal import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material3.Badge import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItem 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.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.login.LocalLoginDataModel import com.shicheeng.copymanga.ui.theme.ElevationTokens private const val AVATAR_HOST_URL = "https://hi77-overseas.mangafuna.xyz/" fun hostFor(string: String): String { return if (string.toUri().scheme == "https") { string } else { buildString { append(AVATAR_HOST_URL) append(string) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PersonalHeaderView( localLoginDataModel: LocalLoginDataModel?, click: () -> Unit, ) { val backgroundV = MaterialTheme.colorScheme.surfaceColorAtElevation(ElevationTokens.Level1) Column( modifier = Modifier.fillMaxWidth() ) { Image( painter = painterResource(id = R.drawable.undraw_personal_file_re), contentDescription = null, modifier = Modifier .height(150.dp) .fillMaxWidth() .padding(horizontal = 16.dp) .clip(MaterialTheme.shapes.large) .drawWithContent { drawRect(color = backgroundV) drawContent() }, contentScale = ContentScale.FillHeight ) Spacer(modifier = Modifier.height(16.dp)) ListItem( headlineContent = { Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { Text( text = localLoginDataModel?.nikeName ?: stringResource(id = R.string.no_login) ) if (localLoginDataModel?.isExpired == true) { Badge( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.padding(start = 4.dp) ) { Text( text = stringResource(id = R.string.login_expired), modifier = Modifier.padding(4.dp) ) } } } }, leadingContent = { if (localLoginDataModel != null) { AsyncImage( model = hostFor(localLoginDataModel.avatarImageUrl), contentDescription = null, modifier = Modifier .size(72.dp) .clip(CircleShape), placeholder = ColorPainter(MaterialTheme.colorScheme.secondaryContainer) ) } else { AvatarPlaceholder() } }, supportingContent = if (localLoginDataModel != null) { { Text(text = localLoginDataModel.userName) } } else null, trailingContent = { Icon( imageVector = Icons.Default.KeyboardArrowRight, contentDescription = stringResource(id = R.string.topic_detail_text) ) }, modifier = Modifier.clickable { click() } ) } } @Composable fun AvatarPlaceholder() { Image( painter = painterResource(id = R.drawable.undraw_drink_coffee), contentDescription = null, modifier = Modifier .size(72.dp) .clip(CircleShape) ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/personal/PersonalScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.personal import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.viewmodel.PersonalViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PersonalScreen( viewModel: PersonalViewModel = hiltViewModel(), onHistoryClick: () -> Unit, onLibraryClick: () -> Unit, onDownloadClick: () -> Unit, onSubscribedClick: () -> Unit, onPersonalHeaderClick: (isHadLoginUser: Boolean) -> Unit, onSettingClick: () -> Unit, ) { val user by viewModel.user.collectAsState() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.personal)) } ) } ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = it ) { item( key = PersonalToken.HEADER, contentType = PersonalToken.HEADER ) { PersonalHeaderView(localLoginDataModel = user) { onPersonalHeaderClick(user != null) } } item( key = PersonalToken.TOP_DIVIDER, contentType = PersonalToken.TOP_DIVIDER ) { HorizontalDivider() } item( key = PersonalToken.ITEM_HISTORY, contentType = PersonalToken.ITEM_HISTORY ) { PersonalItem( id = R.string.history, iconId = R.drawable.baseline_history_24, onClick = onHistoryClick ) } item { PersonalItem( id = R.string.subscribe, iconId = R.drawable.baseline_rss_feed_24, onClick = onSubscribedClick ) } item( key = PersonalToken.ITEM_BOOK_SHELF, contentType = PersonalToken.ITEM_BOOK_SHELF ) { PersonalItem( id = R.string.shelf_cloud, iconId = R.drawable.outline_library_books_24, onClick = onLibraryClick ) } item( key = PersonalToken.ITEM_DOWNLOADED_MANGA, contentType = PersonalToken.ITEM_DOWNLOADED_MANGA ) { PersonalItem( id = R.string.download_manga, iconId = R.drawable.outline_download_24, onClick = onDownloadClick ) } item( key = PersonalToken.BOTTOM_DIVIDER, contentType = PersonalToken.BOTTOM_DIVIDER ) { HorizontalDivider() } item( key = PersonalToken.ITEM_SETTING, contentType = PersonalToken.ITEM_SETTING ) { PersonalItem( id = R.string.setting, iconId = R.drawable.ic_setting_outline, onClick = onSettingClick ) } } } } @Composable private fun PersonalItem( @StringRes id: Int, @DrawableRes iconId: Int, onClick: () -> Unit, ) { ListItem( headlineContent = { Text(text = stringResource(id = id)) }, leadingContent = { Icon( painter = painterResource(id = iconId), contentDescription = null ) }, modifier = Modifier.clickable { onClick() } ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/personal/PersonalToken.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.personal enum class PersonalToken { HEADER, ITEM_HISTORY, BOTTOM_DIVIDER, ITEM_BOOK_SHELF, ITEM_SETTING, TOP_DIVIDER, ITEM_DOWNLOADED_MANGA; } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/personal/personaldetail/PersonalDetail.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.personal.personaldetail import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.logininfoshort.LoginInfoShortDataModel import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.viewmodel.PersonalDetailViewModel import retrofit2.HttpException @OptIn(ExperimentalMaterial3Api::class) @Composable fun PersonalDetail( personalDetailViewModel: PersonalDetailViewModel = hiltViewModel(), onReLogin: () -> Unit, onBack: () -> Unit, ) { val data by personalDetailViewModel.data.collectAsState() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(R.string.personal_info)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) } ) } ) { padding -> when (data) { is UIState.Success -> { val primary = (data as UIState.Success).content Column( Modifier.padding(padding) ) { PersonalDetailTwoRowText( secondaryText = primary.results.info.nickname, primaryText = stringResource(R.string.nickname_text) ) PersonalDetailTwoRowText( secondaryText = primary.results.info.username, primaryText = stringResource(id = R.string.user_name_text) ) PersonalDetailTwoRowText( primaryText = stringResource(R.string.gender), secondaryText = primary.results.info.gender.display ) } } is UIState.Loading -> { LoadingScreen() } is UIState.Error<*> -> { val error = (data as UIState.Error<*>).errorMessage ErrorScreen( errorMessage = error.message ?: "", needSecondaryText = (error is HttpException) && (error.code() == 401), secondaryText = stringResource(id = R.string.re_login), onTry = { personalDetailViewModel.retry() }, onSecondaryClick = onReLogin ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/personal/personaldetail/PersonalDetailTwoRowText.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.personal.personaldetail import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable fun PersonalDetailTwoRowText( primaryText: () -> String, secondaryText: () -> String, ) { Row( modifier = Modifier.fillMaxSize() ) { Text( text = primaryText(), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) Text( text = secondaryText(), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @Composable fun PersonalDetailTwoRowText( primaryText: String, secondaryText: String, ) { Row( modifier = Modifier .padding(horizontal = 16.dp, vertical = 4.dp) ) { Text( text = primaryText, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) Text( text = secondaryText, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/main/subscribe/SubscribedScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.main.subscribe import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.shicheeng.copymanga.R import com.shicheeng.copymanga.server.work.DetectMangaUpdateWork import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.list.CommonListItem import com.shicheeng.copymanga.util.copyComposable import com.shicheeng.copymanga.viewmodel.SubscribedViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SubScribeScreen( viewModel: SubscribedViewModel = hiltViewModel(), navClick: () -> Unit, onPathWord: (String) -> Unit, ) { val data by viewModel.data.collectAsState() val context = LocalContext.current val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.subscribe)) }, actions = { PlainButton( id = R.string.refresh, drawableRes = R.drawable.ic_baseline_loop ) { val workManager = WorkManager.getInstance(context) val oneTimeWorkRequest = OneTimeWorkRequestBuilder() .build() workManager.enqueue(oneTimeWorkRequest) } }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = navClick ) }, scrollBehavior = topAppBarScrollBehavior ) } ) { paddingValues -> LazyVerticalGrid( contentPadding = paddingValues.copyComposable( start = 16.dp, end = 16.dp, top = paddingValues.calculateTopPadding() + 16.dp, bottom = paddingValues.calculateBottomPadding() + 16.dp ), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), columns = GridCells.Fixed(3), modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { items(data) { historyItem -> CommonListItem( url = historyItem.url, title = historyItem.name, author = historyItem.authorList.joinToString { it.name } ) { onPathWord(historyItem.pathWord) } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaChapterComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.util.UIState /** * 这是章节的列表,当列状态为[UIState.Error]不会显示任何内容。 */ fun LazyListScope.chapterList( inSelectMode: Boolean, selectChapters: List, chapterState: UIState>, webLookedUUID: String?, onLongClick: (LocalChapter) -> Unit, onClick: (LocalChapter) -> Unit, ) { if (chapterState is UIState.Error<*> || chapterState == UIState.Loading) { return } val successState = chapterState as UIState.Success items( items = successState.content, key = { it.hashCode() }, contentType = { MangaDetailKey.LIST_CHAPTER } ) { eachChapter -> ChapterItem( inSelectMode = inSelectMode, isSelected = selectChapters.contains(eachChapter), title = eachChapter.name, time = eachChapter.datetime_created, isDownload = eachChapter.isDownloaded, onLongClick = { onLongClick(eachChapter) }, readIn = if ( eachChapter.isReadProgress && !eachChapter.isReadFinish && eachChapter.readIndex != (eachChapter.size - 1) ) { eachChapter.readIndex } else null, isRead = eachChapter.isReadFinish || eachChapter.readIndex == (eachChapter.size - 1), isWebLooked = eachChapter.uuid == webLookedUUID ) { onClick(eachChapter) } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun ChapterItem( inSelectMode: Boolean, isSelected: Boolean, isRead: Boolean, title: String, time: String, readIn: Int? = null, isDownload: Boolean = false, isWebLooked: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit, ) { val textAlpha = remember(isRead) { if (isRead) .38f else 1f } val textSubtitleAlpha = remember(isRead) { if (isRead) .38f else 0.78f } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .background( if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.26f) else Color.Transparent ) .fillMaxWidth() .combinedClickable( onClick = { if (inSelectMode) { onLongClick() } else { onClick.invoke() } }, onLongClick = { onLongClick.invoke() } ) .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), ) { Row( verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(1f) ) { Text( text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.alpha(textAlpha) ) Spacer(modifier = Modifier.height(6.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( text = time, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp), modifier = Modifier.alpha(textSubtitleAlpha) ) when { readIn != null -> { Text( text = stringResource( id = R.string.read_in, formatArgs = arrayOf(readIn + 1) ), overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .alpha(textSubtitleAlpha) .padding(start = 4.dp) ) } isRead -> { Text( text = stringResource(R.string.read_finished), overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall, modifier = Modifier .alpha(textSubtitleAlpha) .padding(start = 4.dp) ) } } } } if (isWebLooked) { Icon( painter = painterResource(id = R.drawable.outline_cloud_24), contentDescription = stringResource(id = R.string.shelf_cloud), modifier = Modifier.padding(end = 4.dp) ) } if (isDownload) { Icon( painter = painterResource(id = R.drawable.outline_download_for_offline_24), contentDescription = stringResource(id = R.string.download_manga) ) } } } } @Composable fun TipDialog( onDismiss: () -> Unit, onPositive: () -> Unit, onNegative: () -> Unit, ) = AlertDialog( onDismissRequest = onDismiss, confirmButton = { Button( onClick = { onPositive() onDismiss() } ) { Text(text = stringResource(R.string.enable)) } onDismiss() }, properties = DialogProperties(), dismissButton = { Button( onClick = { onNegative() onDismiss() } ) { Text(text = stringResource(R.string.not_enabled)) } }, title = { Text(text = stringResource(R.string.confrim_update)) }, text = { Text(text = stringResource(R.string.enable_update_text)) } ) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailBottomBar.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CornerSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.theme.ElevationTokens @Composable fun MangaDetailBottomBar( modifier: Modifier = Modifier, bottomCornerSize: CornerSize = CornerSize(0.dp), onDownloadClick: () -> Unit, onMarkReadClick: () -> Unit, onMarkNoReadClick: () -> Unit, ) { Surface( shape = MaterialTheme.shapes.large.copy( bottomStart = bottomCornerSize, bottomEnd = bottomCornerSize ), tonalElevation = ElevationTokens.Level2, modifier = modifier.fillMaxWidth() ) { Row( horizontalArrangement = Arrangement.SpaceAround, modifier = Modifier.padding(all = 16.dp) ) { PlainButton( id = R.string.download_manga, drawableRes = R.drawable.outline_download_24, onButtonClick = onDownloadClick ) PlainButton( id = R.string.mark_to_read, drawableRes = R.drawable.ic_done_all, onButtonClick = onMarkReadClick ) PlainButton( id = R.string.mark_to_no_read, drawableRes = R.drawable.baseline_remove_done_24, onButtonClick = onMarkNoReadClick ) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailBottomSelector.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.info.Author @OptIn(ExperimentalMaterial3Api::class) @Composable fun MangaDetailBottomSelector( list: List, onDismissRequest: () -> Unit, onClick: (String) -> Unit, ) = ModalBottomSheet( onDismissRequest = onDismissRequest, windowInsets = WindowInsets( left = 0, top = 0, right = 0, bottom = 0 ) ) { Text( text = stringResource(R.string.author_choice), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp) ) HorizontalDivider() list.forEach { author -> ListItem( headlineContent = { Text(text = author.name) }, modifier = Modifier.clickable { onClick(author.pathWord) onDismissRequest() }, leadingContent = { Icon( painter = painterResource(id = R.drawable.baseline_person_24), contentDescription = null ) }, supportingContent = { Text(text = author.pathWord) } ) } HorizontalDivider() Text( text = stringResource(R.string.author_combine, list.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .padding(16.dp) .navigationBarsPadding() ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailHeader.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer 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.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle 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.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.ui.screen.compoents.MangaCover import com.shicheeng.copymanga.util.click @Composable fun DetailInfoBox( modifier: Modifier = Modifier, mangaInfoDataModel: MangaHistoryDataModel, topPadding: Dp, onAuthorClicked: () -> Unit, ) { Box(modifier = modifier) { val backDropColors = listOf( Color.Transparent, MaterialTheme.colorScheme.background ) AsyncImage( model = mangaInfoDataModel.url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .matchParentSize() .drawWithContent { drawContent() drawRect( brush = Brush.verticalGradient(backDropColors) ) } .blur(4.dp) .alpha(.2f) ) DetailHeader( mangaInfoDataModel = mangaInfoDataModel, onAuthorClicked = onAuthorClicked, topPadding = topPadding ) } } @Composable fun DetailHeader( mangaInfoDataModel: MangaHistoryDataModel, topPadding: Dp, onAuthorClicked: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp + topPadding, start = 16.dp, end = 16.dp) ) { MangaCover.Small( url = mangaInfoDataModel.url, shape = MaterialTheme.shapes.medium ) Column( modifier = Modifier .weight(1f) .padding(start = 16.dp) ) { Text( text = mangaInfoDataModel.name, style = MaterialTheme.typography.titleLarge ) Spacer(modifier = Modifier.height(2.dp)) Text( text = mangaInfoDataModel.alias ?: stringResource(id = R.string.no_alias), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = mangaInfoDataModel.authorList.joinToString { it.name }, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(top = 4.dp) .click { onAuthorClicked() } ) Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource( id = when (mangaInfoDataModel.mangaStatusId) { 0 -> { R.drawable.ic_baseline_loop } 1 -> { R.drawable.ic_done_all } else -> { R.drawable.outline_do_not_disturb_24 } } ), contentDescription = null, modifier = Modifier .padding(end = 4.dp) .size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) { Text( text = mangaInfoDataModel.mangaStatus, color = MaterialTheme.colorScheme.onSurfaceVariant ) DotText() Text( text = mangaInfoDataModel.mangaRegion, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(id = R.drawable.ic_baseline_hot), contentDescription = null, modifier = Modifier .padding(end = 4.dp) .size(16.dp) ) Text( text = mangaInfoDataModel.mangaPopularNumber, style = MaterialTheme.typography.bodyMedium ) } Text( text = stringResource( R.string.last_update, mangaInfoDataModel.mangaLastUpdate ), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 4.dp), style = MaterialTheme.typography.bodyMedium ) } } } @Composable fun DetailRowInfo( isCollect: Boolean, isSubscribed: Boolean, onCollectClicked: () -> Unit, onSubscribedClick: () -> Unit, onCommentClick: () -> Unit, ) { /*从Tachiyomi直接复制来的,这个比较简单就不自己想办法写了*/ val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f) Row( modifier = Modifier .fillMaxWidth() .height(IntrinsicSize.Min) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically ) { MangaActionButton( title = stringResource(if (isCollect) R.string.remove_add_to_lib else R.string.add_to_lib), icon = ImageVector.vectorResource( id = if (isCollect) { R.drawable.baseline_library_add_check_24 } else { R.drawable.baseline_library_add_24 } ), color = if (isCollect) MaterialTheme.colorScheme.primary else defaultActionButtonColor, onClick = onCollectClicked ) MangaActionButton( title = stringResource( id = if (isSubscribed) { R.string.unsubscribe_for_updates } else { R.string.subscribe_for_updates } ), icon = ImageVector.vectorResource( id = if (isSubscribed) { R.drawable.iconmonstr_rss_feed_baseline } else { R.drawable.iconmonstr_rss_feed_outline } ), color = if (isSubscribed) MaterialTheme.colorScheme.primary else defaultActionButtonColor, onClick = onSubscribedClick ) MangaActionButton( title = stringResource(id = R.string.comment_text), icon = ImageVector.vectorResource(R.drawable.outline_comment_24), color = MaterialTheme.colorScheme.primary, onClick = onCommentClick ) } } @Composable private fun RowScope.MangaActionButton( title: String, icon: ImageVector, color: Color, onClick: () -> Unit, ) { TextButton( onClick = onClick, modifier = Modifier.weight(1f), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( imageVector = icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp), ) Spacer(Modifier.height(4.dp)) Text( text = title, color = color, fontSize = 12.sp, textAlign = TextAlign.Center, ) } } } @Composable private fun DotText() { Text(text = " • ") } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailKey.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga enum class MangaDetailKey { HEADER,ROW_INFO,SUMMARY,LIST_DESC,LIST_CHAPTER,BOTTOM_DESC } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf 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.alpha import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.LocalSettingPreference import com.shicheeng.copymanga.MangaReaderActivity import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.SwipeRefresh import com.shicheeng.copymanga.ui.screen.compoents.pullrefresh.rememberSwipeRefreshState import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.viewmodel.MangaInfoViewModel import soup.compose.material.motion.MaterialFade @OptIn(ExperimentalMaterial3Api::class) @Composable fun MangaDetailScreen( pathWord: String?, viewModel: MangaInfoViewModel = hiltViewModel(), onTagsClick: (MangaSortBean) -> Unit, onAuthorClick: (String) -> Unit, onCommentClick: (comicUUID: String) -> Unit, onNavigation: () -> Unit, ) { val content by viewModel.mangaInfo.collectAsState() val chapters by viewModel.chapters.collectAsState() val selectedChapters by viewModel.selectChapter.collectAsState() val lastWatchChapter by viewModel.lastWatchChapter.collectAsState() val lastWebWatchChapter by viewModel.lastWebLookedChapter.collectAsState() if (content is UIState.Loading) { LoadingScreen() return } if (content is UIState.Error<*>) { ErrorScreen( errorMessage = (content as UIState.Error<*>).errorMessage.message ?: stringResource(id = R.string.error) ) { viewModel.chapterLoadForce() } return } val contentSuccess = content as UIState.Success val layoutDirection = LocalLayoutDirection.current val context = LocalContext.current val refreshState = rememberSwipeRefreshState(isRefreshing = chapters is UIState.Loading) var expanded by remember { mutableStateOf(false) } val inSelectedMode by remember { derivedStateOf { selectedChapters.isNotEmpty() } } var tipDialogShow by remember { mutableStateOf(false) } val setting = LocalSettingPreference.current val haptic = LocalHapticFeedback.current var bottomAuthorsSelector by remember { mutableStateOf(false) } val lazyListState = rememberLazyListState() Scaffold( topBar = { val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } val firstVisibleItemScrollOffset by remember { derivedStateOf { lazyListState.firstVisibleItemScrollOffset } } val animatedTitleAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0) 1f else 0f, label = "titleAlpha", ) val animatedBgAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, label = "bgAlpha", ) TopAppBar( title = { Text( text = stringResource(id = R.string.manga_detail), modifier = Modifier.alpha( if (inSelectedMode) 1f else animatedTitleAlpha, ), maxLines = 1, overflow = TextOverflow.Ellipsis ) }, navigationIcon = { if (inSelectedMode) { PlainButton( id = R.string.exit_select_mode, drawableRes = R.drawable.baseline_close_24 ) { viewModel.deselectedAllItem() } } else { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNavigation ) } }, actions = { if (!inSelectedMode) { Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) ) { PlainButton( id = R.string.download_manga, drawableRes = R.drawable.outline_file_download_24 ) { expanded = true } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier .widthIn(min = 112.dp, max = 280.dp) .zIndex(1f) ) { DropdownMenuItem( text = { Text(text = stringResource(R.string.download_first_5)) }, onClick = { val firstChapters = viewModel.selectFirst5() ?: emptyList() viewModel.downloadManga( firstChapters .map { x -> x.uuid } .toTypedArray() ) expanded = false } ) DropdownMenuItem( text = { Text(text = stringResource(R.string.download_last_5)) }, onClick = { val lastChapters = viewModel.selectLast5() ?: emptyList() pathWord?.let { lastChapters .map { x -> x.uuid } .toTypedArray() } expanded = false } ) DropdownMenuItem( text = { Text(text = stringResource(id = R.string.download_all)) }, onClick = { if (chapters is UIState.Success) { val uuids = ((chapters as UIState.Success>).content) .map { x -> x.uuid } .toTypedArray() viewModel.downloadManga(uuids) } expanded = false } ) } } } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme .surfaceColorAtElevation(3.dp) .copy(alpha = if (inSelectedMode) 1f else animatedBgAlpha), ), ) }, modifier = Modifier.fillMaxSize(), floatingActionButton = { MaterialFade( visible = chapters is UIState.Success && !inSelectedMode ) { ExtendedFloatingActionButton( onClick = { if (setting.webReadPoint) { //漫画UUID val uuid = lastWebWatchChapter?.results?.browse?.chapterUuid ?: lastWatchChapter?.uuid ?: (chapters as UIState.Success).content[0].uuid //漫画PathWord val pathWord2 = lastWebWatchChapter?.results?.browse?.pathWord ?: lastWatchChapter?.comicPathWord ?: pathWord ?: (chapters as UIState.Success).content[0].comicPathWord val intent = MangaReaderActivity.newInstance( context = context, pathWord = pathWord2, uuid = uuid ) context.startActivity(intent) } else { (lastWatchChapter ?: (chapters as UIState.Success).content[0]).run { val intent = MangaReaderActivity.newInstance( context = context, pathWord = comicPathWord, uuid = uuid ) context.startActivity(intent) } } }, text = { Text( text = if (lastWatchChapter != null && contentSuccess.content.positionChapter != 0) { stringResource(id = R.string.continue_read) } else { stringResource(id = R.string.start_read) } ) }, icon = { Icon( painter = painterResource(id = R.drawable.baseline_play_arrow_24), contentDescription = null ) }, expanded = true ) } }, bottomBar = { AnimatedVisibility( visible = inSelectedMode, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { MangaDetailBottomBar( onDownloadClick = { val downloadUUIDs = selectedChapters.map { it.uuid }.toTypedArray() viewModel.downloadManga(downloadUUIDs) viewModel.deselectedAllItem() }, onMarkReadClick = { viewModel.comicMarkRead(isRead = true) } ) { viewModel.comicMarkRead(isRead = false) } } } ) { paddingValues -> SwipeRefresh( state = refreshState, onRefresh = { viewModel.chapterLoadForce() }, indicatorPadding = paddingValues ) { LazyColumn( modifier = Modifier .fillMaxSize(), contentPadding = paddingValues.copy( layoutDirection = layoutDirection, bottom = paddingValues.calculateBottomPadding() + 64.dp, top = 0.dp ), state = lazyListState ) { item( key = MangaDetailKey.HEADER, contentType = MangaDetailKey.HEADER ) { DetailInfoBox( mangaInfoDataModel = contentSuccess.content, topPadding = paddingValues.calculateTopPadding() ) { bottomAuthorsSelector = true } } item( key = MangaDetailKey.ROW_INFO, contentType = MangaDetailKey.ROW_INFO ) { DetailRowInfo( onCommentClick = { onCommentClick(contentSuccess.content.comicUUID) }, isCollect = lastWebWatchChapter?.results?.collect != null, onCollectClicked = { viewModel.comicAddWebLib( mangaUUID = contentSuccess.content.comicUUID, add = lastWebWatchChapter?.results?.collect == null ) }, isSubscribed = contentSuccess.content.isSubscribe, onSubscribedClick = { tipDialogShow = true viewModel.comicUpdate(contentSuccess.content.isSubscribe.not()) } ) } item( key = MangaDetailKey.SUMMARY, contentType = MangaDetailKey.SUMMARY ) { MangaExpandSummary( defaultExpandState = false, description = contentSuccess.content.mangaDetail, tags = { contentSuccess.content.themeList }, onTagsClick = onTagsClick ) } item( key = MangaDetailKey.LIST_DESC, contentType = MangaDetailKey.LIST_DESC ) { Text( text = stringResource(R.string.chapters_list), modifier = Modifier.padding(all = 16.dp), style = MaterialTheme.typography.titleMedium ) } chapterList( selectChapters = selectedChapters, inSelectMode = inSelectedMode, chapterState = chapters, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) viewModel.selectItem(it, !selectedChapters.contains(it)) }, webLookedUUID = lastWebWatchChapter?.results?.browse?.chapterUuid ) { val intent = MangaReaderActivity.newInstance(context, it.comicPathWord, it.uuid) context.startActivity(intent) } item( key = MangaDetailKey.BOTTOM_DESC, contentType = MangaDetailKey.BOTTOM_DESC ) { Column( modifier = Modifier.padding(16.dp) ) { Icon( painter = painterResource(id = R.drawable.ic_manga_info_main), contentDescription = stringResource(R.string.disclaimer) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.general_tips), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = stringResource(R.string.general_warning), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } } if (tipDialogShow && !setting.hasKey(SettingPref.KEY_ENABLE_COMIC_UPDATE)) { TipDialog( onDismiss = { tipDialogShow = false }, onPositive = { viewModel.enableComicUpdate(true) } ) { viewModel.enableComicUpdate(false) } } if (bottomAuthorsSelector) { MangaDetailBottomSelector( list = contentSuccess.content.authorList, onDismissRequest = { bottomAuthorsSelector = false }, onClick = { onAuthorClick(it) } ) } BackHandler(enabled = inSelectedMode) { viewModel.deselectedAllItem() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailSummary.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.util.click import kotlin.math.roundToInt private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE)) @OptIn(ExperimentalLayoutApi::class) @Composable fun MangaExpandSummary( modifier: Modifier = Modifier, defaultExpandState: Boolean, description: String?, tags: () -> List?, onTagsClick: (MangaSortBean) -> Unit, ) { Column(modifier = modifier) { val (expand, onExpand) = rememberSaveable { mutableStateOf(defaultExpandState) } val desc = description.takeIf { !it.isNullOrBlank() } ?: stringResource(id = R.string.no_description) val trimmedDescription = remember(desc) { desc.replace(whitespaceLineRegex, "\n") .trimEnd() } MangaSummary( expandedDescription = desc, shrunkDescription = trimmedDescription, expanded = expand, modifier = Modifier .padding(top = 8.dp) .padding(horizontal = 16.dp) .click { onExpand(!expand) } ) val tag = tags() if (tag != null) { FlowRow( modifier = Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { tag.forEach { SuggestionChip( onClick = { onTagsClick(it) }, label = { Text(text = it.pathName) } ) } } } } } @OptIn(ExperimentalAnimationGraphicsApi::class) @Composable private fun MangaSummary( expandedDescription: String, shrunkDescription: String, expanded: Boolean, modifier: Modifier = Modifier, ) { var expandedHeight by remember { mutableIntStateOf(0) } var shrunkHeight by remember { mutableIntStateOf(0) } val heightDelta = remember(expandedHeight, shrunkHeight) { expandedHeight - shrunkHeight } val animProgress by animateFloatAsState(if (expanded) 1f else 0f) val scrimHeight = with(LocalDensity.current) { remember { 24.sp.roundToPx() } } SubcomposeLayout(modifier = modifier.clipToBounds()) { constraints -> val shrunkPlaceable = subcompose("description-s") { Text( text = "\n\n", // Shows at least 3 lines style = MaterialTheme.typography.bodyMedium, ) }.map { it.measure(constraints) } shrunkHeight = shrunkPlaceable.maxByOrNull { it.height }?.height ?: 0 val expandedPlaceable = subcompose("description-l") { Text( text = expandedDescription, style = MaterialTheme.typography.bodyMedium, ) }.map { it.measure(constraints) } expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0 val actualPlaceable = subcompose("description") { SelectionContainer { Text( text = if (expanded) expandedDescription else shrunkDescription, maxLines = Int.MAX_VALUE, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.alpha(.78f), ) } }.map { it.measure(constraints) } val scrimPlaceable = subcompose("scrim") { val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background) Box( modifier = Modifier.background(Brush.verticalGradient(colors = colors)), contentAlignment = Alignment.Center, ) { val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_arrow_avd) Icon( painter = rememberAnimatedVectorPainter(image, !expanded), contentDescription = stringResource(if (expanded) R.string.manga_info_collapse else R.string.manga_info_expand), tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())), ) } }.map { it.measure(Constraints.fixed(width = constraints.maxWidth, height = scrimHeight)) } val currentHeight = shrunkHeight + ((heightDelta + scrimHeight) * animProgress).roundToInt() layout(constraints.maxWidth, currentHeight) { actualPlaceable.forEach { it.place(0, 0) } val scrimY = currentHeight - scrimHeight scrimPlaceable.forEach { it.place(0, scrimY) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/manga/MangaDetailVerticalIcon.kt ================================================ package com.shicheeng.copymanga.ui.screen.manga import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.shicheeng.copymanga.util.click @Composable fun VerticalIcon( modifier: Modifier = Modifier, @DrawableRes iconId: () -> Int, text: String, click: () -> Unit, ) { Column( modifier = modifier.click { click() }, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(id = iconId()), contentDescription = text, modifier = Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onSurface ) Text( text = text, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 4.dp), style = MaterialTheme.typography.labelMedium ) } } @Composable fun VerticalIcon( modifier: Modifier = Modifier, @DrawableRes iconId: () -> Int, text: String, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(id = iconId()), contentDescription = text, modifier = Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onSurface ) Text( text = text, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 4.dp), style = MaterialTheme.typography.labelMedium ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/search/SearchResultScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.search import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.LazyVerticalGrid import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.search.SearchResultDataModel import com.shicheeng.copymanga.ui.screen.compoents.EmptyDataScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.ui.screen.list.CommonListItem import com.shicheeng.copymanga.util.copyComposable import com.shicheeng.copymanga.viewmodel.SearchResultViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchResultScreen( searchWord: String?, viewModel: SearchResultViewModel = hiltViewModel(), onNavigation: () -> Unit, onItemClick: (SearchResultDataModel) -> Unit, ) { val searchResultList = viewModel.searchResult.collectAsLazyPagingItems() if (searchWord != null) { LaunchedEffect(key1 = searchWord) { viewModel.loadSearch(searchWord) } } Scaffold( topBar = { TopAppBar( title = { Column(modifier = Modifier.fillMaxWidth()) { Text( text = stringResource(id = R.string.search_result), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface ) Text( text = searchWord ?: stringResource(id = android.R.string.unknownName), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onNavigation ) } ) } ) { Box( modifier = Modifier .fillMaxSize(), ) { if (searchResultList.itemSnapshotList.isNotEmpty()) { LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = it.copyComposable( end = 16.dp, start = 16.dp, ), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxSize() ) { items(searchResultList.itemCount) { itemIndex -> searchResultList[itemIndex]?.let { item -> CommonListItem( url = item.cover, title = item.name, author = item.authorReformation() ) { onItemClick(item) } } } pagingLoadingIndication( loadState = searchResultList.loadState.append ) { searchResultList.retry() } } } else { EmptyDataScreen( tipText = stringResource(id = R.string.search_is_empty) ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/Setting.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import com.shicheeng.copymanga.LocalSettingPreference import com.shicheeng.copymanga.R import com.shicheeng.copymanga.fm.domain.makeDirIfNoExist import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.server.work.DetectMangaUpdateWork import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.VerticalFastScroller import com.shicheeng.copymanga.util.FileCacheUtils import com.shicheeng.copymanga.util.ThemeMode import com.shicheeng.copymanga.util.copy import com.shicheeng.copymanga.util.setSystemNightMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SettingScreen( viewModel: SettingViewModel = hiltViewModel(), onNavigateClick: () -> Unit, onDownloadClick: () -> Unit, onWorkerClick: () -> Unit, onUserClick: () -> Unit, onAboutClick: () -> Unit, ) { val settingPref = LocalSettingPreference.current val layoutDirection = LocalLayoutDirection.current val context = LocalContext.current val resource = context.resources val exCacheDir = context.cacheDir val cache = getFileCacheDir(context) val coroutine = rememberCoroutineScope() var cacheDirSummary by remember { mutableStateOf(cache.getSize()) } var cachePageSize by remember { mutableStateOf(exCacheDir.getSize()) } val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val readerMode by viewModel.readerMode.collectAsState() val isUseForeignReq by viewModel.useForeignRequest.collectAsState() val apiSelect by viewModel.apiSelected.collectAsState() val turnOn by viewModel.isTurn.collectAsState() val lazyListState = rememberLazyListState() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.setting)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back ) { onNavigateClick() } }, scrollBehavior = topAppBarScrollBehavior ) } ) { padding -> VerticalFastScroller( listState = lazyListState, topContentPadding = padding.calculateTopPadding() ) { LazyColumn( contentPadding = padding.copy( layoutDirection = layoutDirection, bottom = padding.calculateBottomPadding() ), state = lazyListState, modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { groupText(R.string.login_text) item { val model by viewModel.name.collectAsState() Preference( title = stringResource(id = R.string.login_personal), summary = model?.nikeName ?: stringResource(R.string.no_login), leaderIconRes = R.drawable.ic_person_center ) { onUserClick() } } item { val webPoint by viewModel.webPoint.collectAsState() SwitchPreference( title = stringResource(R.string.web_point_enable), summary = stringResource(R.string.web_point_enable_summary), selectValue = webPoint, leaderIconRes = R.drawable.baseline_webhook_24, onClick = { settingPref.enableWebReadPoint(it) } ) } item { WarningPreference(supportText = stringResource(R.string.web_point_warning)) } item { TipPreference(supportText = stringResource(R.string.web_point_tip)) } groupText(R.string.pref_main) item { val array1 = rememberSaveable { resource.getStringArray(R.array.orientation_array) } ListPreference( title = stringResource(id = R.string.reader_mode_tip), summary = array1[settingPref.readerModeEntity.indexOf(readerMode)], dialogSupportedText = stringResource(id = R.string.swith_reader_mode_dialog_summary), array = array1, selectValue = array1[settingPref.readerModeEntity.indexOf(readerMode)], arrayValue = settingPref.readerModeEntity, leaderIconRes = R.drawable.outline_chrome_reader_mode_24 ) { index, array -> viewModel.setReaderMode(ReaderMode.valueOf(array[index])) } } item { SwitchPreference( title = stringResource(id = R.string.use_forgin_region), summary = stringResource(id = R.string.use_forgin_region_summary), selectValue = isUseForeignReq, leaderIconRes = R.drawable.outline_switch_access_shortcut_24 ) { viewModel.isUseForeignRequest(isUse = it) } } item { TipPreference(supportText = stringResource(id = R.string.use_forgin_region_tip)) } item { ListPreference( title = stringResource(id = R.string.select_api_header), summary = apiSelect, dialogSupportedText = stringResource(id = R.string.switch_api_dialog_summary), array = resource.getStringArray(R.array.api_header), selectValue = apiSelect, arrayValue = resource.getStringArray(R.array.api_header_value), leaderIconRes = R.drawable.outline_cell_wifi_24, ) { index, array -> viewModel.selectApi(array[index]) } } item { SwitchPreference( title = stringResource(id = R.string.subscribe_for_updates), summary = stringResource(id = R.string.subscribe_for_updates_summary), selectValue = turnOn, leaderIconRes = R.drawable.baseline_rss_feed_24 ) { settingPref.enableComicsUpdateFetch(it) DetectMangaUpdateWork.readyToStart( isEnable = it, context = context, settingPref = settingPref ) } } item { TipPreference(supportText = stringResource(R.string.tip_subscribe)) } item(key = "KEY_EDIT_TEXT_FOR_UPDATE_DETECT") { AnimatedVisibility( visible = turnOn, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { val time by viewModel.timeInterval.collectAsState() EditTextPreference( title = stringResource(id = R.string.time_detect), summary = time.toString(), dialogSupportedText = stringResource(id = R.string.time_detect_summary), originalValue = time.toString(), leaderIconRes = R.drawable.outline_timer_24, modifier = Modifier.animateItemPlacement(), onInput = { settingPref.editTimeInterval(it.toInt()) DetectMangaUpdateWork.readyToStart( isEnable = settingPref.enableComicsUpdate.value, context = context, settingPref = settingPref, takeInterval = it.toInt() ) } ) } } item { val dataMap = mapOf( IN_WIFI to stringResource(id = R.string.only_wifi), IN_CHARGING to stringResource(id = R.string.only_charging), IN_BATTERY_NOT_LOW to stringResource(id = R.string.low_power) ) val selected by viewModel.updateConstants.collectAsState() MutableSelectListPreference( title = stringResource(id = R.string.update_constant), dialogSupportedText = stringResource(id = R.string.update_constant_support_text), mapValue = dataMap, selectValue = selected, leaderIconRes = R.drawable.outline_auto_mode_24, summaryProvider = { valueMap -> valueMap.filter { selected.contains(it.key) }.values.takeIf { it.isNotEmpty() } ?.joinToString() ?: stringResource(id = R.string.no_select_constants) }, onOK = { settingPref.changeUpdateConstant(it) ContextCompat.getMainExecutor(context).execute { DetectMangaUpdateWork.readyToStart( isEnable = settingPref.enableComicsUpdate.value, context = context, settingPref = settingPref ) } } ) } groupText(R.string.system) item { val themeName by viewModel.themeModeName.collectAsState() val array2 = arrayOf( stringResource(id = R.string.theme_with_system), stringResource(id = R.string.theme_light), stringResource(id = R.string.theme_dark), ) ListPreference( title = stringResource(id = R.string.theme_mode), summary = when (ThemeMode.valueOf(themeName)) { ThemeMode.SYSTEM -> stringResource(id = R.string.theme_with_system) ThemeMode.DARK -> stringResource(id = R.string.theme_dark) ThemeMode.LIGHT -> stringResource(id = R.string.theme_light) }, dialogSupportedText = stringResource(id = R.string.theme_mode_support_text), array = array2, selectValue = array2[settingPref.themeModeEntity.indexOf(themeName)], arrayValue = settingPref.themeModeEntity, leaderIconRes = R.drawable.outline_contrast_24, onItemClick = { i: Int, strings: Array -> viewModel.setThemeMode(strings[i]) setSystemNightMode(ThemeMode.valueOf(strings[i])) (context as Activity).recreate() } ) } item { val isUse by settingPref.hyperTouch.collectAsState() SwitchPreference( title = stringResource(id = R.string.quick_touch), summary = stringResource(id = R.string.quick_touch_summary), selectValue = isUse, leaderIconRes = R.drawable.outline_touch_app_24, onClick = settingPref::isUseHyperTouch ) } item { val enable by viewModel.cutoutDisplay.collectAsState() SwitchPreference( title = stringResource(id = R.string.cut_out_display), summary = stringResource(id = R.string.cut_out_display_summary), selectValue = enable, leaderIconRes = R.drawable.baseline_content_cut_24, onClick = viewModel::switchCutoutDisplay ) } item { val isPause by settingPref.pauseUpdateDetector.collectAsState() SwitchPreference( title = stringResource(id = R.string.disable_update_detect), summary = stringResource(id = R.string.disable_update_summary), selectValue = isPause, leaderIconRes = R.drawable.outline_update_disabled_24, onClick = settingPref::isPauseDetectUpdate ) } item { val cacheSize by viewModel.cacheSize.collectAsState() EditTextPreference( title = stringResource(R.string.cache_size_setting), summary = "$cacheSize MB", dialogSupportedText = stringResource(R.string.cache_size_setting_supporting_text), originalValue = cacheSize, leaderIconRes = R.drawable.baseline_cached_24, onInput = { viewModel.setCacheSize(it) } ) } item { Preference( title = stringResource(id = R.string.clear_cache), summary = cacheDirSummary, leaderIconRes = R.drawable.outline_clean_hands_24 ) { coroutine.launch { clearCache(cache) { cacheDirSummary = it } } } } item { Preference( title = stringResource(id = R.string.clear_pager_cache), summary = cachePageSize, leaderIconRes = R.drawable.outline_cleaning_services_24 ) { coroutine.launch { clearCache(exCacheDir) { cachePageSize = it } } } } item { Preference( title = stringResource(R.string.work_information), summary = stringResource(R.string.work_info_summary), leaderIconRes = R.drawable.outline_work_history_24, onClick = onWorkerClick ) } groupText(R.string.download_manga) item { val onlyOnWifi by viewModel.onlyInWifi.collectAsState() SwitchPreference( title = stringResource(R.string.only_on_wifi), summary = stringResource(R.string.only_on_wifi_summary), selectValue = onlyOnWifi, leaderIconRes = R.drawable.outline_wifi_lock_24, onClick = { viewModel.changeDownloadConstants(it) } ) } item { Preference( title = stringResource(R.string.download_list), summary = stringResource(id = R.string.see_the_download), leaderIconRes = R.drawable.outline_file_download_24, onClick = onDownloadClick ) } groupText(R.string.perf_about) item { Preference( title = stringResource(id = R.string.pref_app), summary = stringResource(id = R.string.pref_app_summary), leaderIconRes = R.drawable.iconmonstr_github_5 ) { val intent = Intent( Intent.ACTION_VIEW, Uri.parse("https://github.com/shizheng233/CopyMangaJava") ) context.startActivity(intent) } } item { Preference( title = stringResource(id = R.string.about), summary = stringResource(id = R.string.pref_about_summary), leaderIconRes = R.drawable.ic_manga_info_main, onClick = onAboutClick, ) } } } } } private fun getFileCacheDir(context: Context): File { return (context.externalCacheDirs + context.cacheDir).firstNotNullOfOrNull { it.makeDirIfNoExist() }.let { file -> checkNotNull(file) { val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") { it.absolutePath } "Cannot find directory for PagesCache: [$dirs]" } } } private suspend inline fun clearCache( file: File, crossinline onFinished: (String) -> Unit, ) = withContext(Dispatchers.IO) { try { file.deleteRecursively() val size = file.getSize() onFinished(size) } catch (e: Exception) { e.printStackTrace() } } private fun File.getSize(): String { return FileCacheUtils.getFormatSize(FileCacheUtils.getFolderSize(this).toDouble()) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/SettingComponents.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.core.view.HapticFeedbackConstantsCompat import com.shicheeng.copymanga.R /** * 使用compose写的设置界面。基本构件,表示在首选项层次结构中向用户显示的单个设置。处理最简单的点击事件。 * * @param title 使用 String 设置此首选项的标题。 * @param summary 使用 String 设置此首选项的摘要。 * @param leaderIconRes 使用 DrawableResID 设置此首选项的图标。 * @param onClick 设置单击此首选项时要调用的回调。 */ @Composable fun Preference( modifier: Modifier = Modifier, title: String, summary: String, @DrawableRes leaderIconRes: Int?, onClick: () -> Unit, ) { ListItem( headlineContent = { Text(text = title) }, supportingContent = { Text(text = summary) }, leadingContent = { if (leaderIconRes != null) { Icon( painter = painterResource(id = leaderIconRes), contentDescription = null, modifier = Modifier .padding(preferenceSpace) .size(24.dp), ) } else { Spacer(modifier = Modifier.size(preferenceSpaceSize)) } }, modifier = modifier .clickable { onClick() }, ) } /** * 使用compose写的将条目列表显示为对话框的首选项。该首选项保存一个字符串值,该控件在点击时回弹出一个对话框来让用户选择与[array]对应的[arrayValue],二者下标对应。 * * @param dialogSupportedText 必传参数,用于显示对话框的介绍 * @param title 必传参数,使用 String 设置此首选项的标题 * @param summary 必传参数,使用 String 设置此首选项的摘要 * @param array 必传参数,对话框的列表显示 * @param selectValue 必传参数,对话框的被选择值 * @param arrayValue 必传参数,与[array]下标对应的值 * @param leaderIconRes 必传参数,使用 DrawableResID 设置此首选项的图标 * @param onItemClick 点击列表的回调事件 */ @Composable fun ListPreference( title: String, summary: String, dialogSupportedText: String, array: Array, selectValue: String, arrayValue: Array, @DrawableRes leaderIconRes: Int, onItemClick: (Int, Array) -> Unit, ) { var isShow by remember { mutableStateOf(false) } Preference(title = title, summary = summary, leaderIconRes = leaderIconRes) { isShow = true } if (isShow) { SelectionDialog( title = title, summaryText = dialogSupportedText, array = array, selectValue = selectValue, iconRes = leaderIconRes, onItemClick = { onItemClick(it, arrayValue) isShow = false }, onCancel = { isShow = false } ) { isShow = false } } } /** * 使用compose写的将条目列表显示为对话框的首选项。该首选项保存一个字符串值,该控件在点击时回弹出一个对话框来让用户选择[mapValue]里面的值。 * * @param dialogSupportedText 必传参数,用于显示对话框的介绍 * @param title 必传参数,使用 String 设置此首选项的标题 * @param summaryProvider 必传参数,使用 String 设置此首选项的摘要 * @param mapValue 必传参数,对话框的列表显示,选择的为[Map.keys],显示的为[Map.values] * @param selectValue 必传参数,对话框的被选择值 * @param leaderIconRes 必传参数,使用 DrawableResID 设置此首选项的图标 * @param onOK 点击列表下面的按钮的回调事件 */ @Composable fun MutableSelectListPreference( title: String, dialogSupportedText: String, mapValue: Map, summaryProvider: @Composable (Map) -> String, selectValue: Set, @DrawableRes leaderIconRes: Int, onOK: (Set) -> Unit, ) { var isShow by remember { mutableStateOf(false) } Preference( title = title, summary = summaryProvider(mapValue), leaderIconRes = leaderIconRes ) { isShow = true } if (isShow) { MutableSelectedDialog( title = title, summaryText = dialogSupportedText, mapValue = mapValue, selectValues = selectValue, iconRes = leaderIconRes, onCancel = { isShow = false }, onOk = { onOK(it) isShow = false } ) { isShow = false } } } /** * 使用compose写的开关设置组件,提供双状态可切换选项的首选项。 * * @param title 使用 String 设置此首选项的标题。 * @param summary 使用 String 设置此首选项的摘要。 * @param leaderIconRes 使用 DrawableResID 设置此首选项的图标。 * @param onClick 设置单击此首选项时要调用的回调。 * @param selectValue 开关的选择 */ @Composable fun SwitchPreference( title: String, summary: String, selectValue: Boolean, @DrawableRes leaderIconRes: Int?, onClick: (Boolean) -> Unit, ) { val mutableInteractionSource = remember { MutableInteractionSource() } val hapticFeedbackConstants = LocalHapticFeedback.current ListItem( headlineContent = { Text(text = title) }, supportingContent = { Text(text = summary) }, trailingContent = { Switch( checked = selectValue, onCheckedChange = onClick, interactionSource = mutableInteractionSource, ) }, leadingContent = { if (leaderIconRes != null) { Icon( painter = painterResource(id = leaderIconRes), contentDescription = null, modifier = Modifier .padding(preferenceSpace) .size(24.dp), ) } else { Spacer(modifier = Modifier.size(preferenceSpaceSize)) } }, modifier = Modifier.toggleable( value = selectValue, interactionSource = mutableInteractionSource, indication = LocalIndication.current, role = Role.Switch ) { onClick(it) hapticFeedbackConstants.performHapticFeedback( hapticFeedbackType = HapticFeedbackType( value = if (it) { HapticFeedbackConstantsCompat.TOGGLE_ON } else { HapticFeedbackConstantsCompat.TOGGLE_OFF } ) ) } ) } /** * 使用compose写的提示首选项,处理类似于[SwitchPreference]或者[Preference]这种有着需要被注意的细节问题。 * * **注意**:不能用来写类似简介的文字,这个应该**只用来**提示用户。 * * @param supportText 必传参数,显示的文字。 */ @Composable fun TipPreference( supportText: String, ) { ListItem( headlineContent = { Row( Modifier.padding(start = 16.dp + 48.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( painter = painterResource(id = R.drawable.ic_manga_info_main), contentDescription = null ) Text( text = supportText, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 8.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) } } ) } /** * 使用compose写的警告首选项,处理类似于[SwitchPreference]或者[Preference]这种有着需要被注意的问题。 * * **注意**:不能用来写类似简介的文字,这个应该**只用来**警告用户。 * * @param supportText 必传参数,显示的文字。 */ @Composable fun WarningPreference( supportText: String, ) { ListItem( headlineContent = { Row( Modifier.padding(start = 16.dp + 48.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( painter = painterResource(id = R.drawable.baseline_warning_amber_24), contentDescription = null ) Text( text = supportText, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 8.dp), ) } } ) } /** * 使用compose写的将输入框显示为对话框的首选项。该首选项保存一个字符串值,该控件在点击时回弹出一个对话框来让用户选择输入文字。 * * @param dialogSupportedText 必传参数,用于显示对话框的介绍 * @param title 必传参数,使用 String 设置此首选项的标题 * @param summary 必传参数,使用 String 设置此首选项的摘要 * @param originalValue 必传参数,原始数据 * @param leaderIconRes 必传参数,使用 DrawableResID 设置此首选项的图标。 * @param onInput 必传参数,输入确认的回调 */ @Composable fun EditTextPreference( modifier: Modifier = Modifier, title: String, summary: String, dialogSupportedText: String, originalValue: String, @DrawableRes leaderIconRes: Int, onInput: (String) -> Unit, ) { var isShow by remember { mutableStateOf(false) } Preference( modifier = modifier, title = title, summary = summary, leaderIconRes = leaderIconRes ) { isShow = true } if (isShow) { EditTextDialog( title = title, summaryText = dialogSupportedText, originalValue = originalValue, onDone = { onInput(it) isShow = false } ) { isShow = false } } } fun LazyListScope.groupText( @StringRes text: Int, ) { item( key = text ) { Text( text = stringResource(id = text), Modifier .padding(16.dp) .padding(start = 48.dp + 16.dp), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary ) } } private val preferenceSpace = PaddingValues(all = 12.dp) private val preferenceSpaceSize = 48.dp ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/SettingDialog.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting import androidx.annotation.DrawableRes import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Checkbox import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.shicheeng.copymanga.R @Composable fun SelectionDialog( title: String, summaryText: String?, @DrawableRes iconRes: Int = R.drawable.baseline_format_list_bulleted_24, array: Array, selectValue: String, onItemClick: (Int) -> Unit, onCancel: () -> Unit, onDismissListener: () -> Unit, ) = Dialog( onDismissRequest = onDismissListener ) { Surface( shape = RoundedCornerShape(28.dp), tonalElevation = 6.dp, modifier = Modifier.widthIn(min = 280.dp, max = 560.dp) ) { Column( modifier = Modifier.padding(all = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(id = iconRes), contentDescription = null ) Spacer(modifier = Modifier.height(16.dp)) Text( text = title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) if (summaryText != null) { Spacer(modifier = Modifier.height(16.dp)) Text( text = summaryText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth() ) } Spacer(modifier = Modifier.height(16.dp)) Divider() array.forEach { val interactionSource = remember { MutableInteractionSource() } Row( Modifier .fillMaxWidth() .height(56.dp) .selectable( selected = (it == selectValue), onClick = { onItemClick(array.indexOf(it)) }, role = Role.RadioButton, interactionSource = interactionSource, indication = LocalIndication.current ), verticalAlignment = Alignment.CenterVertically ) { RadioButton( selected = (it == selectValue), onClick = { onItemClick(array.indexOf(it)) }, interactionSource = interactionSource ) Text( text = it, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 16.dp) ) } } Divider() Spacer(modifier = Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { TextButton( onClick = onCancel ) { Text(text = stringResource(id = android.R.string.cancel)) } } } } } @Composable fun EditTextDialog( title: String, summaryText: String, originalValue: String, onDone: (String) -> Unit, onDismissListener: () -> Unit, ) = Dialog( onDismissRequest = onDismissListener, ) { var inputValue by remember { mutableStateOf(originalValue) } Surface( shape = RoundedCornerShape(28.dp), tonalElevation = 6.dp, modifier = Modifier.widthIn(min = 280.dp, max = 560.dp) ) { Column( modifier = Modifier.padding(all = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(id = R.drawable.outline_input_24), contentDescription = null ) Spacer(modifier = Modifier.height(16.dp)) Text( text = title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = summaryText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( value = inputValue, onValueChange = { inputValue = it }, placeholder = { Text(text = stringResource(R.string.input)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions(onDone = { onDone(inputValue) }), modifier = Modifier.fillMaxWidth(), singleLine = true, ) Spacer(modifier = Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { TextButton( onClick = { onDone(inputValue) } ) { Text(text = stringResource(id = android.R.string.ok)) } } } } } @Composable fun MutableSelectedDialog( title: String, summaryText: String?, @DrawableRes iconRes: Int = R.drawable.baseline_format_list_bulleted_24, mapValue: Map, selectValues: Set, onOk: (Set) -> Unit, onCancel: () -> Unit, onDismissListener: () -> Unit, ) = Dialog( onDismissRequest = onDismissListener ) { val array = remember { mapValue.keys.filter { selectValues.contains(it) }.toMutableStateList() } Surface( shape = RoundedCornerShape(28.dp), tonalElevation = 6.dp, modifier = Modifier.widthIn(min = 280.dp, max = 560.dp) ) { Column( modifier = Modifier.padding(all = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(id = iconRes), contentDescription = null ) Spacer(modifier = Modifier.height(16.dp)) Text( text = title, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) if (summaryText != null) { Spacer(modifier = Modifier.height(16.dp)) Text( text = summaryText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth() ) } Spacer(modifier = Modifier.height(16.dp)) Divider() mapValue.forEach { current -> val interactionSource = remember { MutableInteractionSource() } val isSelected = array.contains(current.key) Row( Modifier .selectable( selected = isSelected, onClick = { if (!isSelected) array.add(current.key) else array.remove(current.key) }, interactionSource = interactionSource, role = Role.Checkbox, indication = LocalIndication.current ) .fillMaxWidth() .height(56.dp), verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = isSelected, onCheckedChange = { if (!isSelected) array.add(current.key) else array.remove(current.key) }, interactionSource = interactionSource ) Text( text = current.value, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 16.dp) ) } } Divider() Spacer(modifier = Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { TextButton( onClick = onCancel ) { Text(text = stringResource(id = android.R.string.cancel)) } Spacer(modifier = Modifier.width(8.dp)) TextButton( onClick = { onOk(array.toMutableSet()) } ) { Text(text = stringResource(id = android.R.string.ok)) } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/SettingPref.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting import android.content.Context import androidx.core.content.edit import androidx.preference.PreferenceManager import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.util.ThemeMode import com.shicheeng.copymanga.util.booleanFlow import com.shicheeng.copymanga.util.stringFlow import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @Singleton class SettingPref @Inject constructor( @ApplicationContext private val context: Context, ) { private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val readerMode: String get() = sharedPreferences.getString( KEY_ORIENTATION_PREF, ReaderMode.NORMAL.name ) ?: ReaderMode.NORMAL.name fun setReaderMode(mode: ReaderMode) { sharedPreferences.edit { putString(KEY_ORIENTATION_PREF, mode.name) } } val readerModeEntity = arrayOf( ReaderMode.NORMAL.name, ReaderMode.WEBTOON.name, ReaderMode.STANDARD.name ) val themeModeEntity = arrayOf( ThemeMode.SYSTEM.name, ThemeMode.LIGHT.name, ThemeMode.DARK.name, ) var useForeignApi: Boolean get() = sharedPreferences.getBoolean(KEY_USE_FOREIGN_API, false) set(value) { sharedPreferences.edit { putBoolean(KEY_USE_FOREIGN_API, value) } } var apiSelected: String get() = sharedPreferences.getString(KEY_API_HEADER_SELECT, "copymanga.net") ?: "copymanga.net" set(value) { sharedPreferences.edit { putString(KEY_API_HEADER_SELECT, value) } } private val _hyperTouch: MutableStateFlow = MutableStateFlow(sharedPreferences.getBoolean("key_touch_quick", false)) val hyperTouch = _hyperTouch.asStateFlow() fun isUseHyperTouch(isUse: Boolean) { sharedPreferences.edit { putBoolean("key_touch_quick", isUse) } _hyperTouch.tryEmit(sharedPreferences.getBoolean("key_touch_quick", false)) } private val _pauseUpdateDetector: MutableStateFlow = MutableStateFlow(sharedPreferences.getBoolean("disable_update", false)) val pauseUpdateDetector = _pauseUpdateDetector.asStateFlow() fun isPauseDetectUpdate(isPause: Boolean) { sharedPreferences.edit { putBoolean("disable_update", isPause) } _pauseUpdateDetector.tryEmit(sharedPreferences.getBoolean("disable_update", isPause)) } val enableComicsUpdate: MutableStateFlow = MutableStateFlow(sharedPreferences.getBoolean(KEY_ENABLE_COMIC_UPDATE, false)) fun hasKey(key: String): Boolean { return sharedPreferences.contains(key) } fun enableComicsUpdateFetch(enable: Boolean) { sharedPreferences.edit { putBoolean(KEY_ENABLE_COMIC_UPDATE, enable) } enableComicsUpdate.tryEmit(sharedPreferences.getBoolean(KEY_ENABLE_COMIC_UPDATE, false)) } val timeInterval = MutableStateFlow( if (hasKey(KEY_COMIC_UPDATE_TIME)) { sharedPreferences.getInt(KEY_COMIC_UPDATE_TIME, 6) } else { 6 } ) fun editTimeInterval(time: Int) { sharedPreferences.edit { putInt(KEY_COMIC_UPDATE_TIME, time) } timeInterval.tryEmit(sharedPreferences.getInt(KEY_COMIC_UPDATE_TIME, 6)) } val updateConstant = MutableStateFlow( if (hasKey(KEY_COMIC_UPDATE_CONS)) { sharedPreferences.getStringSet(KEY_COMIC_UPDATE_CONS, setOf(IN_WIFI)) ?: setOf(IN_WIFI) } else { setOf(IN_WIFI) } ) fun changeUpdateConstant(value: Set) { sharedPreferences.edit { putStringSet(KEY_COMIC_UPDATE_CONS, value) } updateConstant.tryEmit( sharedPreferences.getStringSet( KEY_COMIC_UPDATE_CONS, setOf(IN_WIFI) ) ?: setOf(IN_WIFI) ) } var appThemeMode get() = sharedPreferences.getString(KEY_APP_THEME, ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name set(value) { sharedPreferences.edit { putString(KEY_APP_THEME, value) } } var cutoutDisplay get() = sharedPreferences.getBoolean(KEY_CUTOUT_DISPLAY, true) set(value) { sharedPreferences.edit { putBoolean(KEY_CUTOUT_DISPLAY, value) } } var cacheSize get() = sharedPreferences.getString(KEY_CACHE_SIZE, "400") ?: "400" set(value) { sharedPreferences.edit { putString(KEY_CACHE_SIZE, value) } } val loginPerson get() = sharedPreferences.getString(KEY_LOGIN_STATUS, null) val loginPersonalFlow get() = sharedPreferences.stringFlow(KEY_LOGIN_STATUS) fun selectedUUId(uuid: String?) { sharedPreferences.edit { putString(KEY_LOGIN_STATUS, uuid) } } val webReadPoint get() = sharedPreferences.getBoolean( KEY_ENABLE_WEB_READ_POINT, false ) val webReadPointFlow get() = sharedPreferences.booleanFlow(KEY_ENABLE_WEB_READ_POINT) fun enableWebReadPoint(boolean: Boolean) { sharedPreferences.edit { putBoolean(KEY_ENABLE_WEB_READ_POINT, boolean) } } var downloadOnlyOnWifi get() = sharedPreferences.getBoolean(KEY_DOWNLOAD_ONLY_ON_WIFI, true) set(value) { sharedPreferences.edit { putBoolean(KEY_DOWNLOAD_ONLY_ON_WIFI, value) } } val downloadOnlyOnWifiFlow = sharedPreferences.booleanFlow(KEY_DOWNLOAD_ONLY_ON_WIFI) companion object { const val KEY_ENABLE_COMIC_UPDATE = "KEY_ENABLE_COMIC_UPDATE" const val KEY_COMIC_UPDATE_TIME = "KEY_COMIC_UPDATE_TIME" const val KEY_COMIC_UPDATE_CONS = "KEY_COMIC_UPDATE_CONS" const val KEY_APP_THEME = "KEY_APP_THEME" const val KEY_CUTOUT_DISPLAY = "KEY_CUTOUT_DISPLAY" const val KEY_CACHE_SIZE = "KEY_CACHE_SIZE" const val KEY_LOGIN_STATUS = "KEY_LOGIN_STATUS" const val KEY_ENABLE_WEB_READ_POINT = "KEY_ENABLE_WEB_READ_POINT" const val KEY_DOWNLOAD_ONLY_ON_WIFI = "KEY_WIFI_DOWNLOAD" const val KEY_ORIENTATION_PREF = "pref_orientation_key" const val KEY_USE_FOREIGN_API = "pref_is_use_foreign_api" const val KEY_API_HEADER_SELECT = "key_api_header_select" } } const val IN_WIFI = "IN_WIFI" const val IN_CHARGING = "IN_CHARGING" const val IN_BATTERY_NOT_LOW = "IN_BATTERY_NOT_LOW" ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/SettingViewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.resposity.LoginRepository import com.shicheeng.copymanga.server.download.woker.DownloadedWorker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SettingViewModel @Inject constructor( private val settingPref: SettingPref, private val repository: LoginRepository, private val callWorker: DownloadedWorker.Caller, ) : ViewModel() { private val _readerMode = MutableStateFlow(settingPref.readerMode) val readerMode = _readerMode.asStateFlow() private val _useForeignRequest = MutableStateFlow(settingPref.useForeignApi) val useForeignRequest = _useForeignRequest.asStateFlow() private val _apiSelected = MutableStateFlow(settingPref.apiSelected) val apiSelected = _apiSelected.asStateFlow() val isTurn = settingPref.enableComicsUpdate.asStateFlow() val timeInterval = settingPref.timeInterval.asStateFlow() val updateConstants = settingPref.updateConstant.asStateFlow() private val _themeModeName = MutableStateFlow(settingPref.appThemeMode) val themeModeName = _themeModeName.asStateFlow() private val _cutoutDisplay = MutableStateFlow(settingPref.cutoutDisplay) val cutoutDisplay = _cutoutDisplay.asStateFlow() private val _cacheSize = MutableStateFlow(settingPref.cacheSize) val cacheSize = _cacheSize.asStateFlow() val webPoint = settingPref.webReadPointFlow .stateIn( scope = viewModelScope, initialValue = settingPref.webReadPoint, started = SharingStarted.Eagerly ) val onlyInWifi = settingPref.downloadOnlyOnWifiFlow .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = settingPref.downloadOnlyOnWifi ) @OptIn(ExperimentalCoroutinesApi::class) val name = settingPref.loginPersonalFlow .stateIn( started = SharingStarted.Eagerly, scope = viewModelScope, initialValue = settingPref.loginPerson ) .filter { !it.isNullOrBlank() || !it.isNullOrEmpty() } .filterNotNull() .flatMapLatest { repository.getUserByUUid(it) }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = null ) fun setReaderMode(readerMode: ReaderMode) { settingPref.setReaderMode(readerMode) _readerMode.tryEmit(settingPref.readerMode) } fun setThemeMode(themeModeName: String) { settingPref.appThemeMode = themeModeName _themeModeName.tryEmit(settingPref.appThemeMode) } fun isUseForeignRequest(isUse: Boolean) { settingPref.useForeignApi = isUse _useForeignRequest.tryEmit(settingPref.useForeignApi) } fun selectApi(api: String) { settingPref.apiSelected = api _apiSelected.tryEmit(settingPref.apiSelected) } fun switchCutoutDisplay(enable: Boolean) = viewModelScope.launch { settingPref.cutoutDisplay = enable _cutoutDisplay.emit(settingPref.cutoutDisplay) } fun setCacheSize(size: String) = viewModelScope.launch { settingPref.cacheSize = size _cacheSize.emit(size) } fun changeDownloadConstants(onlyWifi: Boolean) = viewModelScope.launch { settingPref.downloadOnlyOnWifi = onlyWifi callWorker.updateConstraints() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/about/About.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting.about import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.contentColorFor 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.draw.drawWithContent import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import com.mikepenz.aboutlibraries.Libs import com.mikepenz.aboutlibraries.entity.Library import com.mikepenz.aboutlibraries.util.author import com.mikepenz.aboutlibraries.util.withContext import com.shicheeng.copymanga.BuildConfig import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.VerticalFastScroller import com.shicheeng.copymanga.util.openUrl @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutScreen( onBack: () -> Unit, ) { val content = LocalContext.current val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val libs = remember(Libs.Builder().withContext(content).build()::libraries) val thankfulApps = rememberThankfulApps() val lazyState = rememberLazyListState() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.about)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) }, scrollBehavior = topAppBarScrollBehavior ) } ) { paddingValues -> VerticalFastScroller( listState = lazyState, topContentPadding = paddingValues.calculateTopPadding() ) { LazyColumn( contentPadding = paddingValues, verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), state = lazyState ) { item { AboutScreenHeader() } item { Text( text = stringResource(R.string.thankful_app), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.primary ) } items(thankfulApps) { aboutUiModel -> ListItem( headlineContent = { Text(text = aboutUiModel.name) }, supportingContent = { Text(text = aboutUiModel.description) }, leadingContent = { Icon( painter = rememberAsyncImagePainter(model = aboutUiModel.url), contentDescription = null ) }, modifier = Modifier.clickable { content openUrl aboutUiModel.url } ) } item { Text( text = stringResource(R.string.open_source), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.primary ) } items(libs) { library -> AboutListItem(library = library) } item { Text( text = stringResource(R.string.general_warning), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutScreenHeader() { val backgroundColor = MaterialTheme.colorScheme.secondaryContainer val density = LocalDensity.current Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Image( contentDescription = "logo", painter = painterResource(id = R.mipmap.ic_copy_foreground), modifier = Modifier .drawWithContent { drawCircle( color = backgroundColor, radius = with(density) { 130 .toDp() .toPx() } ) drawContent() }, colorFilter = ColorFilter .tint(color = contentColorFor(backgroundColor = backgroundColor)) ) Spacer(modifier = Modifier.height(8.dp)) BadgedBox( badge = { Badge( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) { Text(text = BuildConfig.VERSION_NAME) } } ) { Text( text = stringResource(id = R.string.app_name), style = MaterialTheme.typography.titleLarge ) } Spacer(modifier = Modifier.height(2.dp)) Text( text = stringResource(R.string.copy_manga_summary), modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutListItem( library: Library, ) { val name = remember { library.licenses.takeIf { it.isNotEmpty() }?.first()?.name } val context = LocalContext.current OutlinedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), onClick = { library.website?.let { context openUrl it } } ) { Column( modifier = Modifier.padding(16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Icon( painter = painterResource( id = when { name?.contains("MIT", ignoreCase = true) == true -> { R.drawable.legal_license_mit_svgrepo_com } name?.contains("Apache", ignoreCase = true) == true -> { R.drawable.apache_svgrepo_com } else -> { R.drawable.open_source_fill_svgrepo_com } } ), contentDescription = null, modifier = Modifier .padding(8.dp) .size(32.dp) ) Column( modifier = Modifier.padding(bottom = 4.dp), verticalArrangement = Arrangement.Center ) { Text( text = library.name, style = MaterialTheme.typography.titleMedium ) Text( text = library.author.ifBlank { stringResource(id = android.R.string.unknownName) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } HorizontalDivider() Text( text = library.description ?: stringResource(id = android.R.string.unknownName), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 8.dp) ) Row( modifier = Modifier .fillMaxWidth() ) { CompositionLocalProvider( LocalTextStyle provides MaterialTheme.typography.labelSmall, LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant ) { Text( text = library.licenses.takeIf { it.isNotEmpty() } ?.joinToString { it.name } ?: stringResource(id = android.R.string.unknownName), modifier = Modifier.padding(end = 16.dp), maxLines = 1, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.weight(1f)) Text( text = library.artifactVersion ?: stringResource(id = android.R.string.unknownName) ) } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/about/AboutDatas.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting.about import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @Composable fun rememberThankfulApps() = remember { listOf( AboutDataUiModel( name = "Tachiyomi", url = "tachiyomi.org", description = "Free and open source manga reader for Android.", author = "tachiyomiorg", license = ApacheLicense, iconUrl = "https://tachiyomi.org/img/logo-128px.png" ), AboutDataUiModel( name = "Kotatsu", description = "Manga reader for Android", author = "KotatsuApp", license = GPLv3License, url = "https://kotatsu.app/", iconUrl = "https://kotatsu.app/logo-compact.svg" ), AboutDataUiModel( name = "copymanga 拷贝漫画", description = "拷贝漫画的第三方APP,优化阅读/下载体验", author = "fumiama", license = GPLv3License, url = "https://github.com/fumiama/copymanga", iconUrl = "https://raw.githubusercontent.com/fumiama/copymanga/main/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" ), AboutDataUiModel( name = "copymanga-downloader", description = "使用python编译exe/bash/命令行参数来下载copymanga(拷贝漫画)中的漫画,支持批量+选话下载和获取您收藏的漫画并下载!(windows&linux支持,MacOS代码支持)", url = "https://github.com/misaka10843/copymanga-downloader", author = "misaka10843", license = GPLv3License ) ) } data class AboutDataUiModel( val name: String, val url: String, val iconUrl: String? = null, val description: String, val author: String, val license: String, ) private const val GPLv3License = "GNU General Public License v3.0" private const val ApacheLicense = "Apache License 2.0" ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/setting/worker/Worker.kt ================================================ package com.shicheeng.copymanga.ui.screen.setting.worker import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.asFlow import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.theme.MonospaceStyle import com.shicheeng.copymanga.util.copyComposable @OptIn(ExperimentalMaterial3Api::class) @Composable fun WorkerScreen( onBack: () -> Unit, ) { val context = LocalContext.current val workManager = WorkManager.getInstance(context) val successWork by workManager .getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.SUCCEEDED)) .asFlow() .collectAsState(initial = emptyList()) val enqueueWord by workManager .getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)) .asFlow() .collectAsState(initial = emptyList()) val runningWork by workManager .getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING)) .asFlow() .collectAsState(initial = emptyList()) Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.work_information)) }, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) } ) }, modifier = Modifier.fillMaxSize() ) { paddingValues -> LazyColumn( contentPadding = paddingValues.copyComposable( start = 16.dp, end = 16.dp, top = 16.dp + paddingValues.calculateTopPadding(), bottom = 16.dp + paddingValues.calculateBottomPadding() ), verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { Text(text = stringResource(R.string.successed)) } items(successWork) { Text( text = it.tags.joinToString(), style = MonospaceStyle ) Text( text = it.id.toString(), style = MonospaceStyle ) Text( text = it.state.name, style = MonospaceStyle ) } item { Text(text = stringResource(R.string.running)) } items(runningWork) { Text( text = it.tags.joinToString(), style = MonospaceStyle ) Text( text = it.id.toString(), style = MonospaceStyle ) Text( text = it.state.name, style = MonospaceStyle ) } item { Text(text = stringResource(R.string.enqueue)) } items(enqueueWord) { Text( text = it.tags.joinToString(), style = MonospaceStyle ) Text( text = it.id.toString(), style = MonospaceStyle ) Text( text = it.state.name, style = MonospaceStyle ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topiclist/TopicListScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.topiclist import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.topicalllist.TopicAllListItem import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.main.home.HomePageTopicCard import com.shicheeng.copymanga.util.copyComposable @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopicListScreen( viewModel: TopicListVIewModel = hiltViewModel(), onBack: () -> Unit, onTopicClick: (TopicAllListItem) -> Unit, ) { val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val list = viewModel.list.collectAsLazyPagingItems() Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.topic)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) } ) } ) { paddingValues -> LazyColumn( contentPadding = paddingValues.copyComposable( start = 16.dp, end = 16.dp ), modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), verticalArrangement = Arrangement.spacedBy(16.dp) ) { items( count = list.itemCount, key = list.itemKey { it.pathWord }, contentType = list.itemContentType { it.pathWord } ) { index -> list[index]?.let { HomePageTopicCard( title = it.title, supportedText = it.brief, subText = it.period, imageUrl = it.cover, modifier = Modifier.fillMaxWidth() ) { onTopicClick(it) } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topiclist/TopicListVIewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.topiclist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.shicheeng.copymanga.domin.CopyMangaApi import com.shicheeng.copymanga.pagingsource.MangaTopicListPagingSource import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class TopicListVIewModel @Inject constructor( copyMangaApi: CopyMangaApi, ) : ViewModel() { val list = Pager(config = PagingConfig(pageSize = 1)) { MangaTopicListPagingSource(copyMangaApi) }.flow.cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topics/TopicComicItem.kt ================================================ package com.shicheeng.copymanga.ui.screen.topics import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.topiclist.TopicItem import com.shicheeng.copymanga.util.formNumberToRead @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopicComicItem( topicItem: TopicItem, onItemClick: (TopicItem) -> Unit, ) { OutlinedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), onClick = { onItemClick(topicItem) } ) { Row( modifier = Modifier.fillMaxWidth() ) { AsyncImage( model = topicItem.cover, contentDescription = null, modifier = Modifier .width(100.dp) .padding(8.dp) .clip(MaterialTheme.shapes.medium) .aspectRatio(2f / 3f), contentScale = ContentScale.Crop, placeholder = ColorPainter(MaterialTheme.colorScheme.secondaryContainer) ) Column( modifier = Modifier .weight(1f) .padding(vertical = 8.dp) ) { Text( text = topicItem.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) Text( text = topicItem.author.joinToString { it.name }, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary ) Text( text = topicItem.theme.joinToString { it.name }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp) ) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.tertiary ) { Icon( painter = painterResource(id = R.drawable.ic_trend_up), contentDescription = null, modifier = Modifier.size(12.dp) ) Text( text = topicItem.popular.toLong().formNumberToRead(), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp) ) } } } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topics/TopicHeader.kt ================================================ package com.shicheeng.copymanga.ui.screen.topics import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.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.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import coil.compose.AsyncImage @Composable fun TopicHeader( title: String, coverUrl: String, period: String, time: String, createTime: String, ) { Column( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) ) { AsyncImage( model = coverUrl, contentDescription = null, modifier = Modifier .clip(MaterialTheme.shapes.extraLarge) .aspectRatio(5f / 3f), placeholder = ColorPainter(MaterialTheme.colorScheme.secondary), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.height(8.dp)) Text( text = title, style = MaterialTheme.typography.headlineLarge, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), textAlign = TextAlign.Center ) Text( text = "$period • $time • $createTime", style = MaterialTheme.typography.labelLarge, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, maxLines = 1 ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topics/TopicHeaderKeys.kt ================================================ package com.shicheeng.copymanga.ui.screen.topics enum class TopicHeaderKeys { HEADER,SUMMARY } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topics/TopicScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.topics import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.copyComposable @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopicsScreen( topicViewModel: TopicViewModel = hiltViewModel(), onBack: () -> Unit, onItemClick: (pathWord: String) -> Unit, ) { val uiState by topicViewModel.uiState.collectAsState() val list = topicViewModel.list.collectAsLazyPagingItems() if (uiState == UIState.Loading) { LoadingScreen() return } if (uiState is UIState.Error<*>) { ErrorScreen( errorMessage = (uiState as UIState.Error<*>) .errorMessage .message ?: stringResource(id = R.string.error) ) { topicViewModel.retry() list.refresh() } return } val successUIState = uiState as UIState.Success val lazyListState = rememberLazyListState() Scaffold( topBar = { val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } val firstVisibleItemScrollOffset by remember { derivedStateOf { lazyListState.firstVisibleItemScrollOffset } } val animatedTitleAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0) 1f else 0f, label = "animated_title_alpha", ) val animatedBgAlpha by animateFloatAsState( if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, label = "animated_background_alpha", ) TopAppBar( title = { Text( text = stringResource(R.string.topic_detail_text), modifier = Modifier.alpha(animatedTitleAlpha) ) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) .copy(alpha = animatedBgAlpha) ), navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = onBack ) } ) } ) { paddingValues -> LazyColumn( contentPadding = paddingValues.copyComposable( top = 0.dp ), state = lazyListState ) { item( key = TopicHeaderKeys.HEADER, contentType = TopicHeaderKeys.HEADER ) { TopicHeader( title = successUIState.content.results.title, coverUrl = successUIState.content.results.cover, period = successUIState.content.results.period, time = successUIState.content.results.journal, createTime = successUIState.content.results.datetimeCreated ) } item( key = TopicHeaderKeys.SUMMARY, contentType = TopicHeaderKeys.SUMMARY ) { Text( text = successUIState.content.results.brief, style = MaterialTheme.typography.bodyLarge, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), textAlign = TextAlign.Start ) } items( count = list.itemCount, contentType = list.itemContentType { it.pathWord }, key = list.itemKey { it.pathWord } ) { index -> list[index]?.let { topicItem -> TopicComicItem(topicItem = topicItem) { onItemClick(it.pathWord) } } } pagingLoadingIndication( loadState = list.loadState.append ) { list.retry() } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/topics/TopicViewModel.kt ================================================ package com.shicheeng.copymanga.ui.screen.topics import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.shicheeng.copymanga.resposity.MangaTopicDetailRepository import com.shicheeng.copymanga.util.RetryTrigger import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.retryableFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @OptIn(FlowPreview::class) @HiltViewModel class TopicViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaTopicDetailRepository: MangaTopicDetailRepository, ) : ViewModel() { private val _pathWord: MutableStateFlow = MutableStateFlow(savedStateHandle["pathWord"]) private val type: Int = savedStateHandle["type"] ?: 1 private val _retryTiger = RetryTrigger() @OptIn(ExperimentalCoroutinesApi::class) val list = _pathWord .filterNotNull() .flatMapLatest { mangaTopicDetailRepository.mangas(it, type) }.cachedIn(viewModelScope) @OptIn(ExperimentalCoroutinesApi::class) val uiState = retryableFlow(_retryTiger) { _pathWord .filterNotNull() .flatMapLatest { mangaTopicDetailRepository.load(it) } } .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = UIState.Loading ) fun retry() = _retryTiger.retry() } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/screen/webshelf/WebShelfScreen.kt ================================================ package com.shicheeng.copymanga.ui.screen.webshelf import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import com.shicheeng.copymanga.R import com.shicheeng.copymanga.ui.screen.compoents.ErrorScreen import com.shicheeng.copymanga.ui.screen.compoents.LoadingScreen import com.shicheeng.copymanga.ui.screen.compoents.PlainButton import com.shicheeng.copymanga.ui.screen.compoents.RefreshLayout import com.shicheeng.copymanga.ui.screen.compoents.pagingLoadingIndication import com.shicheeng.copymanga.ui.screen.list.CommonListItem import com.shicheeng.copymanga.util.copyComposable import com.shicheeng.copymanga.viewmodel.WebShelfViewModel import retrofit2.HttpException @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun WebShelfScreen( webShelfViewModel: WebShelfViewModel = hiltViewModel(), navClick: () -> Unit, reLoginClick: () -> Unit, onPathWord: (String) -> Unit, ) { val data = webShelfViewModel.data.collectAsLazyPagingItems() val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val pullRefreshState = rememberPullRefreshState( refreshing = data.loadState.refresh is LoadState.Loading, onRefresh = data::refresh ) if (data.loadState.refresh is LoadState.Loading) { LoadingScreen() return } if (data.loadState.refresh is LoadState.Error) { val error = (data.loadState.refresh as LoadState.Error).error ErrorScreen( errorMessage = error.message ?: "", onTry = data::refresh, needSecondaryText = error is HttpException && error.code() == 401, secondaryText = stringResource(id = R.string.re_login), onSecondaryClick = reLoginClick ) return } Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = R.string.shelf_cloud)) }, scrollBehavior = topAppBarScrollBehavior, navigationIcon = { PlainButton( id = R.string.back_to_up, drawableRes = R.drawable.ic_arrow_back, onButtonClick = navClick ) } ) } ) { paddingValues -> RefreshLayout( pullRefreshState = pullRefreshState, isRefreshing = data.loadState.refresh is LoadState.Loading, topPadding = paddingValues.calculateTopPadding() ) { LazyVerticalGrid( contentPadding = paddingValues.copyComposable( start = 16.dp, end = 16.dp ), columns = GridCells.Fixed(3), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) ) { items(data.itemCount) { index -> data[index]?.let { item -> CommonListItem( url = item.comic.cover, title = item.comic.name, author = item.comic.author.joinToString { it.name } ) { onPathWord(item.comic.pathWord) } } } pagingLoadingIndication( loadState = data.loadState.refresh, onTry = data::retry ) } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/theme/Color.kt ================================================ package com.shicheeng.copymanga.ui.theme import androidx.compose.ui.graphics.Color val primaryLight = Color(0xFF3C6090) val onPrimaryLight = Color(0xFFFFFFFF) val primaryContainerLight = Color(0xFFD4E3FF) val onPrimaryContainerLight = Color(0xFF001C3A) val secondaryLight = Color(0xFF545F71) val onSecondaryLight = Color(0xFFFFFFFF) val secondaryContainerLight = Color(0xFFD8E3F8) val onSecondaryContainerLight = Color(0xFF111C2B) val tertiaryLight = Color(0xFF6D5676) val onTertiaryLight = Color(0xFFFFFFFF) val tertiaryContainerLight = Color(0xFFF7D8FF) val onTertiaryContainerLight = Color(0xFF271430) val errorLight = Color(0xFFBA1A1A) val onErrorLight = Color(0xFFFFFFFF) val errorContainerLight = Color(0xFFFFDAD6) val onErrorContainerLight = Color(0xFF410002) val backgroundLight = Color(0xFFF9F9FF) val onBackgroundLight = Color(0xFF191C20) val surfaceLight = Color(0xFFF9F9FF) val onSurfaceLight = Color(0xFF191C20) val surfaceVariantLight = Color(0xFFE0E2EC) val onSurfaceVariantLight = Color(0xFF43474E) val outlineLight = Color(0xFF74777F) val outlineVariantLight = Color(0xFFC3C6CF) val scrimLight = Color(0xFF000000) val inverseSurfaceLight = Color(0xFF2E3035) val inverseOnSurfaceLight = Color(0xFFF0F0F7) val inversePrimaryLight = Color(0xFFA5C8FF) val surfaceDimLight = Color(0xFFD9DAE0) val surfaceBrightLight = Color(0xFFF9F9FF) val surfaceContainerLowestLight = Color(0xFFFFFFFF) val surfaceContainerLowLight = Color(0xFFF2F3FA) val surfaceContainerLight = Color(0xFFEDEDF4) val surfaceContainerHighLight = Color(0xFFE7E8EE) val surfaceContainerHighestLight = Color(0xFFE1E2E9) val primaryLightMediumContrast = Color(0xFF1D4472) val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) val primaryContainerLightMediumContrast = Color(0xFF5376A7) val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) val secondaryLightMediumContrast = Color(0xFF394354) val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) val secondaryContainerLightMediumContrast = Color(0xFF6B7588) val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) val tertiaryLightMediumContrast = Color(0xFF513B59) val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) val tertiaryContainerLightMediumContrast = Color(0xFF856C8D) val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) val errorLightMediumContrast = Color(0xFF8C0009) val onErrorLightMediumContrast = Color(0xFFFFFFFF) val errorContainerLightMediumContrast = Color(0xFFDA342E) val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) val backgroundLightMediumContrast = Color(0xFFF9F9FF) val onBackgroundLightMediumContrast = Color(0xFF191C20) val surfaceLightMediumContrast = Color(0xFFF9F9FF) val onSurfaceLightMediumContrast = Color(0xFF191C20) val surfaceVariantLightMediumContrast = Color(0xFFE0E2EC) val onSurfaceVariantLightMediumContrast = Color(0xFF3F434A) val outlineLightMediumContrast = Color(0xFF5B5F67) val outlineVariantLightMediumContrast = Color(0xFF777B83) val scrimLightMediumContrast = Color(0xFF000000) val inverseSurfaceLightMediumContrast = Color(0xFF2E3035) val inverseOnSurfaceLightMediumContrast = Color(0xFFF0F0F7) val inversePrimaryLightMediumContrast = Color(0xFFA5C8FF) val surfaceDimLightMediumContrast = Color(0xFFD9DAE0) val surfaceBrightLightMediumContrast = Color(0xFFF9F9FF) val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) val surfaceContainerLowLightMediumContrast = Color(0xFFF2F3FA) val surfaceContainerLightMediumContrast = Color(0xFFEDEDF4) val surfaceContainerHighLightMediumContrast = Color(0xFFE7E8EE) val surfaceContainerHighestLightMediumContrast = Color(0xFFE1E2E9) val primaryLightHighContrast = Color(0xFF002246) val onPrimaryLightHighContrast = Color(0xFFFFFFFF) val primaryContainerLightHighContrast = Color(0xFF1D4472) val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) val secondaryLightHighContrast = Color(0xFF182332) val onSecondaryLightHighContrast = Color(0xFFFFFFFF) val secondaryContainerLightHighContrast = Color(0xFF394354) val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) val tertiaryLightHighContrast = Color(0xFF2E1A37) val onTertiaryLightHighContrast = Color(0xFFFFFFFF) val tertiaryContainerLightHighContrast = Color(0xFF513B59) val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) val errorLightHighContrast = Color(0xFF4E0002) val onErrorLightHighContrast = Color(0xFFFFFFFF) val errorContainerLightHighContrast = Color(0xFF8C0009) val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) val backgroundLightHighContrast = Color(0xFFF9F9FF) val onBackgroundLightHighContrast = Color(0xFF191C20) val surfaceLightHighContrast = Color(0xFFF9F9FF) val onSurfaceLightHighContrast = Color(0xFF000000) val surfaceVariantLightHighContrast = Color(0xFFE0E2EC) val onSurfaceVariantLightHighContrast = Color(0xFF20242B) val outlineLightHighContrast = Color(0xFF3F434A) val outlineVariantLightHighContrast = Color(0xFF3F434A) val scrimLightHighContrast = Color(0xFF000000) val inverseSurfaceLightHighContrast = Color(0xFF2E3035) val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) val inversePrimaryLightHighContrast = Color(0xFFE4ECFF) val surfaceDimLightHighContrast = Color(0xFFD9DAE0) val surfaceBrightLightHighContrast = Color(0xFFF9F9FF) val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) val surfaceContainerLowLightHighContrast = Color(0xFFF2F3FA) val surfaceContainerLightHighContrast = Color(0xFFEDEDF4) val surfaceContainerHighLightHighContrast = Color(0xFFE7E8EE) val surfaceContainerHighestLightHighContrast = Color(0xFFE1E2E9) val primaryDark = Color(0xFFA5C8FF) val onPrimaryDark = Color(0xFF00315E) val primaryContainerDark = Color(0xFF224876) val onPrimaryContainerDark = Color(0xFFD4E3FF) val secondaryDark = Color(0xFFBCC7DC) val onSecondaryDark = Color(0xFF263141) val secondaryContainerDark = Color(0xFF3D4758) val onSecondaryContainerDark = Color(0xFFD8E3F8) val tertiaryDark = Color(0xFFDABDE2) val onTertiaryDark = Color(0xFF3D2946) val tertiaryContainerDark = Color(0xFF553F5D) val onTertiaryContainerDark = Color(0xFFF7D8FF) val errorDark = Color(0xFFFFB4AB) val onErrorDark = Color(0xFF690005) val errorContainerDark = Color(0xFF93000A) val onErrorContainerDark = Color(0xFFFFDAD6) val backgroundDark = Color(0xFF111318) val onBackgroundDark = Color(0xFFE1E2E9) val surfaceDark = Color(0xFF111318) val onSurfaceDark = Color(0xFFE1E2E9) val surfaceVariantDark = Color(0xFF43474E) val onSurfaceVariantDark = Color(0xFFC3C6CF) val outlineDark = Color(0xFF8D9199) val outlineVariantDark = Color(0xFF43474E) val scrimDark = Color(0xFF000000) val inverseSurfaceDark = Color(0xFFE1E2E9) val inverseOnSurfaceDark = Color(0xFF2E3035) val inversePrimaryDark = Color(0xFF3C6090) val surfaceDimDark = Color(0xFF111318) val surfaceBrightDark = Color(0xFF37393E) val surfaceContainerLowestDark = Color(0xFF0C0E13) val surfaceContainerLowDark = Color(0xFF191C20) val surfaceContainerDark = Color(0xFF1D2024) val surfaceContainerHighDark = Color(0xFF282A2F) val surfaceContainerHighestDark = Color(0xFF32353A) val primaryDarkMediumContrast = Color(0xFFACCCFF) val onPrimaryDarkMediumContrast = Color(0xFF001631) val primaryContainerDarkMediumContrast = Color(0xFF7092C5) val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) val secondaryDarkMediumContrast = Color(0xFFC1CBE0) val onSecondaryDarkMediumContrast = Color(0xFF0C1726) val secondaryContainerDarkMediumContrast = Color(0xFF8791A4) val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) val tertiaryDarkMediumContrast = Color(0xFFDEC1E7) val onTertiaryDarkMediumContrast = Color(0xFF210E2A) val tertiaryContainerDarkMediumContrast = Color(0xFFA288AB) val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) val errorDarkMediumContrast = Color(0xFFFFBAB1) val onErrorDarkMediumContrast = Color(0xFF370001) val errorContainerDarkMediumContrast = Color(0xFFFF5449) val onErrorContainerDarkMediumContrast = Color(0xFF000000) val backgroundDarkMediumContrast = Color(0xFF111318) val onBackgroundDarkMediumContrast = Color(0xFFE1E2E9) val surfaceDarkMediumContrast = Color(0xFF111318) val onSurfaceDarkMediumContrast = Color(0xFFFBFAFF) val surfaceVariantDarkMediumContrast = Color(0xFF43474E) val onSurfaceVariantDarkMediumContrast = Color(0xFFC8CAD4) val outlineDarkMediumContrast = Color(0xFFA0A3AB) val outlineVariantDarkMediumContrast = Color(0xFF80838B) val scrimDarkMediumContrast = Color(0xFF000000) val inverseSurfaceDarkMediumContrast = Color(0xFFE1E2E9) val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F) val inversePrimaryDarkMediumContrast = Color(0xFF234977) val surfaceDimDarkMediumContrast = Color(0xFF111318) val surfaceBrightDarkMediumContrast = Color(0xFF37393E) val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0E13) val surfaceContainerLowDarkMediumContrast = Color(0xFF191C20) val surfaceContainerDarkMediumContrast = Color(0xFF1D2024) val surfaceContainerHighDarkMediumContrast = Color(0xFF282A2F) val surfaceContainerHighestDarkMediumContrast = Color(0xFF32353A) val primaryDarkHighContrast = Color(0xFFFBFAFF) val onPrimaryDarkHighContrast = Color(0xFF000000) val primaryContainerDarkHighContrast = Color(0xFFACCCFF) val onPrimaryContainerDarkHighContrast = Color(0xFF000000) val secondaryDarkHighContrast = Color(0xFFFBFAFF) val onSecondaryDarkHighContrast = Color(0xFF000000) val secondaryContainerDarkHighContrast = Color(0xFFC1CBE0) val onSecondaryContainerDarkHighContrast = Color(0xFF000000) val tertiaryDarkHighContrast = Color(0xFFFFF9FB) val onTertiaryDarkHighContrast = Color(0xFF000000) val tertiaryContainerDarkHighContrast = Color(0xFFDEC1E7) val onTertiaryContainerDarkHighContrast = Color(0xFF000000) val errorDarkHighContrast = Color(0xFFFFF9F9) val onErrorDarkHighContrast = Color(0xFF000000) val errorContainerDarkHighContrast = Color(0xFFFFBAB1) val onErrorContainerDarkHighContrast = Color(0xFF000000) val backgroundDarkHighContrast = Color(0xFF111318) val onBackgroundDarkHighContrast = Color(0xFFE1E2E9) val surfaceDarkHighContrast = Color(0xFF111318) val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) val surfaceVariantDarkHighContrast = Color(0xFF43474E) val onSurfaceVariantDarkHighContrast = Color(0xFFFBFAFF) val outlineDarkHighContrast = Color(0xFFC8CAD4) val outlineVariantDarkHighContrast = Color(0xFFC8CAD4) val scrimDarkHighContrast = Color(0xFF000000) val inverseSurfaceDarkHighContrast = Color(0xFFE1E2E9) val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) val inversePrimaryDarkHighContrast = Color(0xFF002A53) val surfaceDimDarkHighContrast = Color(0xFF111318) val surfaceBrightDarkHighContrast = Color(0xFF37393E) val surfaceContainerLowestDarkHighContrast = Color(0xFF0C0E13) val surfaceContainerLowDarkHighContrast = Color(0xFF191C20) val surfaceContainerDarkHighContrast = Color(0xFF1D2024) val surfaceContainerHighDarkHighContrast = Color(0xFF282A2F) val surfaceContainerHighestDarkHighContrast = Color(0xFF32353A) ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/theme/ElevationTokens.kt ================================================ package com.shicheeng.copymanga.ui.theme import androidx.compose.ui.unit.dp object ElevationTokens { val Level0 = 0.0.dp val Level1 = 1.0.dp val Level2 = 3.0.dp val Level3 = 6.0.dp val Level4 = 8.0.dp val Level5 = 12.0.dp } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/theme/Theme.kt ================================================ package com.shicheeng.copymanga.ui.theme import android.app.Activity import android.graphics.Color import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import com.google.accompanist.themeadapter.material3.createMdc3Theme private val lightScheme = lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, primaryContainer = primaryContainerLight, onPrimaryContainer = onPrimaryContainerLight, secondary = secondaryLight, onSecondary = onSecondaryLight, secondaryContainer = secondaryContainerLight, onSecondaryContainer = onSecondaryContainerLight, tertiary = tertiaryLight, onTertiary = onTertiaryLight, tertiaryContainer = tertiaryContainerLight, onTertiaryContainer = onTertiaryContainerLight, error = errorLight, onError = onErrorLight, errorContainer = errorContainerLight, onErrorContainer = onErrorContainerLight, background = backgroundLight, onBackground = onBackgroundLight, surface = surfaceLight, onSurface = onSurfaceLight, surfaceVariant = surfaceVariantLight, onSurfaceVariant = onSurfaceVariantLight, outline = outlineLight, outlineVariant = outlineVariantLight, scrim = scrimLight, inverseSurface = inverseSurfaceLight, inverseOnSurface = inverseOnSurfaceLight, inversePrimary = inversePrimaryLight, surfaceDim = surfaceDimLight, surfaceBright = surfaceBrightLight, surfaceContainerLowest = surfaceContainerLowestLight, surfaceContainerLow = surfaceContainerLowLight, surfaceContainer = surfaceContainerLight, surfaceContainerHigh = surfaceContainerHighLight, surfaceContainerHighest = surfaceContainerHighestLight, ) private val darkScheme = darkColorScheme( primary = primaryDark, onPrimary = onPrimaryDark, primaryContainer = primaryContainerDark, onPrimaryContainer = onPrimaryContainerDark, secondary = secondaryDark, onSecondary = onSecondaryDark, secondaryContainer = secondaryContainerDark, onSecondaryContainer = onSecondaryContainerDark, tertiary = tertiaryDark, onTertiary = onTertiaryDark, tertiaryContainer = tertiaryContainerDark, onTertiaryContainer = onTertiaryContainerDark, error = errorDark, onError = onErrorDark, errorContainer = errorContainerDark, onErrorContainer = onErrorContainerDark, background = backgroundDark, onBackground = onBackgroundDark, surface = surfaceDark, onSurface = onSurfaceDark, surfaceVariant = surfaceVariantDark, onSurfaceVariant = onSurfaceVariantDark, outline = outlineDark, outlineVariant = outlineVariantDark, scrim = scrimDark, inverseSurface = inverseSurfaceDark, inverseOnSurface = inverseOnSurfaceDark, inversePrimary = inversePrimaryDark, surfaceDim = surfaceDimDark, surfaceBright = surfaceBrightDark, surfaceContainerLowest = surfaceContainerLowestDark, surfaceContainerLow = surfaceContainerLowDark, surfaceContainer = surfaceContainerDark, surfaceContainerHigh = surfaceContainerHighDark, surfaceContainerHighest = surfaceContainerHighestDark, ) /** * 拷贝漫画的主题 */ @Composable fun CopyMangaTheme( dynamicColor: Boolean = true, darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { val context = LocalContext.current val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> darkScheme else -> lightScheme } val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = Color.TRANSPARENT window.navigationBarColor = Color.TRANSPARENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.isStatusBarContrastEnforced = false window.isNavigationBarContrastEnforced = false } WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme WindowCompat.setDecorFitsSystemWindows(window, false) } } MaterialTheme( content = content, colorScheme = colorScheme, typography = typography ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/ui/theme/Typo.kt ================================================ package com.shicheeng.copymanga.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.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val MonospaceStyle: TextStyle = TextStyle( fontStyle = FontStyle.Normal, fontWeight = FontWeight.Normal, fontFamily = FontFamily.Monospace, fontSize = 12.sp ) val typography = Typography() ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/FileCacheUtils.java ================================================ package com.shicheeng.copymanga.util; import android.content.Context; import java.io.File; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Objects; public class FileCacheUtils { /** * 获取缓存大小 * * @param context 上下文 * @return 大小 */ public static String getCacheSize(Context context) { return getFormatSize(getFolderSize(context.getCacheDir()) + getFolderSize(context.getExternalCacheDir())); } /** * 获取文件大小 */ public static long getFolderSize(File file) { long size = 0; try { File[] fileList = file.listFiles(); for (int i = 0; i < Objects.requireNonNull(fileList).length; i++) { // 如果下面还有文件 if (fileList[i].isDirectory()) { size = size + getFolderSize(fileList[i]); } else { size = size + fileList[i].length(); } } } catch (Exception e) { e.printStackTrace(); } return size; } /** * 格式化文件大小单位 * * @param size 大小 * @return 格式化 */ public static String getFormatSize(double size) { double kiloByte = size / 1024; if (kiloByte < 1) { return size + "Byte"; } double megaByte = kiloByte / 1024; if (megaByte < 1) { BigDecimal result1 = new BigDecimal(Double.toString(kiloByte)); return result1.setScale(2, RoundingMode.HALF_UP) .toPlainString() + "KB"; } double gigaByte = megaByte / 1024; if (gigaByte < 1) { BigDecimal result2 = new BigDecimal(Double.toString(megaByte)); return result2.setScale(2, RoundingMode.HALF_UP) .toPlainString() + "MB"; } double teraBytes = gigaByte / 1024; if (teraBytes < 1) { BigDecimal result3 = new BigDecimal(Double.toString(gigaByte)); return result3.setScale(2, RoundingMode.HALF_UP) .toPlainString() + "GB"; } BigDecimal result4 = new BigDecimal(teraBytes); return result4.setScale(2, RoundingMode.HALF_UP).toPlainString() + "TB"; } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/FirstSnapHelper.kt ================================================ package com.shicheeng.copymanga.util import android.content.Context import android.util.DisplayMetrics import android.view.View import android.view.animation.DecelerateInterpolator import android.widget.Scroller import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.OrientationHelper import androidx.recyclerview.widget.RecyclerView class FirstSnapHelper : LinearSnapHelper() { companion object { private const val MILLISECONDS_PER_INCH = 100f private const val MAX_SCROLL_ON_FLING_DURATION_MS = 1000 } private var context: Context? = null private var helper: OrientationHelper? = null private var scroller: Scroller? = null private var maxScrollDistance: Int = 0 override fun findSnapView(layoutManager: RecyclerView.LayoutManager?): View? { return firstFirstView(layoutManager, helper(layoutManager)) } override fun attachToRecyclerView(recyclerView: RecyclerView?) { if (recyclerView != null) { context = recyclerView.context scroller = Scroller(context, DecelerateInterpolator()) } else { scroller = null context = null } super.attachToRecyclerView(recyclerView) } override fun calculateDistanceToFinalSnap( layoutManager: RecyclerView.LayoutManager, targetView: View, ): IntArray { val out = IntArray(2) out[0] = distanceToStart(targetView, helper(layoutManager)) return out } override fun calculateScrollDistance(velocityX: Int, velocityY: Int): IntArray { val out = IntArray(2) val helper = helper ?: return out if (maxScrollDistance == 0) { maxScrollDistance = (helper.endAfterPadding - helper.startAfterPadding) / 2 } scroller?.fling(0, 0, velocityX, velocityY, -maxScrollDistance, maxScrollDistance, 0, 0) out[0] = scroller?.finalX ?: 0 out[1] = scroller?.finalY ?: 0 return out } override fun createScroller(layoutManager: RecyclerView.LayoutManager): RecyclerView.SmoothScroller? { if (layoutManager !is RecyclerView.SmoothScroller.ScrollVectorProvider) return super.createScroller(layoutManager) val context = context ?: return null return object : LinearSmoothScroller(context) { override fun onTargetFound( targetView: View, state: RecyclerView.State, action: Action, ) { val snapDistance = calculateDistanceToFinalSnap(layoutManager, targetView) val dx = snapDistance[0] val dy = snapDistance[1] val dt = calculateTimeForDeceleration(Math.abs(dx)) val time = 1.coerceAtLeast(MAX_SCROLL_ON_FLING_DURATION_MS.coerceAtMost(dt)) action.update(dx, dy, time, mDecelerateInterpolator) } override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float = MILLISECONDS_PER_INCH / displayMetrics.densityDpi } } private fun distanceToStart(targetView: View, helper: OrientationHelper): Int { val childStart = helper.getDecoratedStart(targetView) val containerStart = helper.startAfterPadding return childStart - containerStart } private fun firstFirstView( layoutManager: RecyclerView.LayoutManager?, helper: OrientationHelper, ): View? { if (layoutManager == null) return null val childCount = layoutManager.childCount if (childCount == 0) return null var absClosest = Int.MAX_VALUE var closestView: View? = null val start = helper.startAfterPadding for (i in 0 until childCount) { val child = layoutManager.getChildAt(i) val childStart = helper.getDecoratedStart(child) val absDistanceToStart = Math.abs(childStart - start) if (absDistanceToStart < absClosest) { absClosest = absDistanceToStart closestView = child } } return closestView } private fun helper(layoutManager: RecyclerView.LayoutManager?): OrientationHelper { if (helper == null) { helper = OrientationHelper.createHorizontalHelper(layoutManager) } return helper!! } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/FunctionUtils.kt ================================================ package com.shicheeng.copymanga.util import android.content.Context import android.content.Intent import android.graphics.Rect import android.graphics.drawable.Drawable import android.net.Uri import android.provider.Settings import android.util.TypedValue import android.view.View import androidx.activity.ComponentActivity import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.MainThread import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.coroutineScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.shape.MaterialShapeDrawable import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okio.Closeable import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* import kotlin.coroutines.CoroutineContext /** * 将[JsonArray]转化为[String]对象。 */ fun JsonArray.authorNameReformation(): String = if (size() == 1) get(0).asJsonObject["name"].asString else get(0).asJsonObject["name"].asString + " 等" @MainThread inline fun Flow.collectRepeatLifecycle( lifecycleOwner: LifecycleOwner, crossinline collected: (T) -> Unit, ) { lifecycleOwner.lifecycle.coroutineScope.launch { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { collectLatest { collected(it) } } } } @MainThread inline fun Fragment.assistedViewModels( noinline factoryProducer: () -> VM, ): Lazy = viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull(modelClass.cast(factoryProducer.invoke())) } } } infix fun Context.openUrl(string: String) { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(string))) } @MainThread inline fun ComponentActivity.assistedViewModels( noinline factoryProducer: () -> VM, ): Lazy = viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull(modelClass.cast(factoryProducer.invoke())) } } } /** * 复制一份[PaddingValues]。区别于[PaddingValues.copyComposable],该函数需要传递[LayoutDirection]。 */ fun PaddingValues.copy( layoutDirection: LayoutDirection, top: Dp = this.calculateTopPadding(), bottom: Dp = this.calculateBottomPadding(), start: Dp = this.calculateStartPadding(layoutDirection), end: Dp = this.calculateEndPadding(layoutDirection), ): PaddingValues { return PaddingValues(start = start, top = top, end = end, bottom = bottom) } /** * 复制一份[PaddingValues]。区别于[PaddingValues.copy],该函数**不**需要传递[LayoutDirection]。 * 并且该函数是[Composable]函数。 */ @Composable fun PaddingValues.copyComposable( layoutDirection: LayoutDirection = LocalLayoutDirection.current, top: Dp = this.calculateTopPadding(), bottom: Dp = this.calculateBottomPadding(), start: Dp = this.calculateStartPadding(layoutDirection), end: Dp = this.calculateEndPadding(layoutDirection), ): PaddingValues { return PaddingValues(start = start, top = top, end = end, bottom = bottom) } /** * * Copy from [tachiyomi](https://github.com/tachiyomiorg/tachiyomi/blob/820ed6a46880af1e9390706dc9915f3c7d385c60/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt) * */ @ColorInt fun Context.getThemeColor(attr: Int): Int { val tv = TypedValue() return if (this.theme.resolveAttribute(attr, tv, true)) { if (tv.resourceId != 0) { getColor(tv.resourceId) } else { tv.data } } else { 0 } } /** * Returns a deep copy of the provided [Drawable] * * Copy from tachiyomi */ inline fun T.copy(context: Context): T? { return (constantState?.newDrawable()?.mutate() as? T).apply { if (this is MaterialShapeDrawable) { initializeElevationOverlay(context) } } } /** * Refer from Kotatsu * * 新旧交替检测 */ @Deprecated("不再使用LiveData") fun LiveData.observeWithPrevious(owner: LifecycleOwner, observer: BufferedObserver) { var previous: T? = null this.observe(owner) { observer.onChanged(it, previous) previous = it } } fun String.parserToJson(): JsonElement = JsonParser.parseString(this) /** * 没有波纹动画的点击监听 */ @OptIn(ExperimentalFoundationApi::class) fun Modifier.click(onClick: () -> Unit) = composed { combinedClickable( onClick = onClick, onLongClick = null, interactionSource = remember { MutableInteractionSource() }, indication = null ) } fun RecyclerView.findCurrentPagePosition(): Int { val x = width / 2f val y = height / 2f val view = findChildViewUnder(x, y) ?: return RecyclerView.NO_POSITION return getChildAdapterPosition(view) } fun String.transformToUUIDMayNull(): UUID? { return try { UUID.fromString(this) } catch (e: Exception) { e.printStackTrace() null } } fun String?.transformToUUIDMayNullSafety(): UUID? { return try { UUID.fromString(this) } catch (e: Exception) { e.printStackTrace() null } } val Exception.messageNoNull: String get() { return if (message == null || message.isNullOrBlank() || message.isNullOrEmpty()) { "ERROR BUT NO MESSAGE" } else { message as String } } fun RecyclerView.setFirstVisibleItemPositionSmooth(position: Int, smooth: Boolean) { if (position != RecyclerView.NO_POSITION) { if (smooth) { smoothScrollToPosition(position) } else { scrollToPosition(position) } } } /** * Return recycler first item position */ var RecyclerView.firstVisibleItemPosition: Int get() = (layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() ?: RecyclerView.NO_POSITION set(value) { if (value != RecyclerView.NO_POSITION) { (layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(value, 0) } } fun String.parserAsJson(): JsonElement = JsonParser.parseString(this) fun JsonElement.transformToJsonObjectSafety(): JsonObject? = try { asJsonObject } catch (e: IllegalStateException) { null } fun JsonObject.getOrNull(member: String): JsonElement? { return if (has(member)) get(member) else null } fun String?.nullWillBe(newString: () -> String): String { return this ?: return newString() } /** * 将大数字转化为可读性数字。没有i18n。 */ fun Long.formNumberToRead(): String { return when { this >= 1000000000 -> { String.format("%.2f 亿", this / 1000000000.0) } this >= 10000000 -> { String.format("%.2f 千万", this / 1000000.0) } this >= 10000 -> { String.format("%.2f 万", this / 10000.0) } this >= 1000 -> { String.format("%.2f 千", this / 1000.0) } else -> this.toString() } } /** * Time convert */ fun String.timeStampConvert(): String { val sfd = SimpleDateFormat("yyyy/MM/dd HH:mm", Locale.ROOT) val timeStamp = Instant.parse(this).toEpochMilli() return sfd.format(timeStamp) } /** * Copy from Kotatsu */ fun View.hasGlobalPoint(x: Int, y: Int): Boolean { if (visibility != View.VISIBLE) { return false } val rect = Rect() getGlobalVisibleRect(rect) return rect.contains(x, y) } suspend fun T.useWithContext( coroutineContext: CoroutineContext, block: (t: T) -> R, ) = withContext(coroutineContext) { use(block = block) } fun JsonObject.add(property: String, jsonElement: () -> T) { add(property, jsonElement()) } fun List.toJsonArray( headerProperty: String, header: (T) -> String, valuesProperty: String, values: (T) -> String, ): JsonArray { val jsonArray = JsonArray() forEach { val jsonObjects = JsonObject().apply { addProperty(headerProperty, header(it)) addProperty(valuesProperty, values(it)) } jsonArray.add(jsonObjects) } return jsonArray } /** * Format long to Time * * The format -> 2023/2/22 12:15 */ @Deprecated("使用更加安全的方法", replaceWith = ReplaceWith("toTimeReadableCompat()")) fun Long.toTimeReadable(): String { val date = Date(this) val sfd = SimpleDateFormat("yyyy/MM/dd HH:mm", Locale.ROOT) return sfd.format(date) } fun Long.convertToTimeGroup(): String { val sfd = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) return sfd.format(Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault())) } fun Long.convertToOnlyTime(): String { val sfd = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) return sfd.format(Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault())) } fun Long.toTimeReadableCompat(): String { val sfd = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) return sfd.format(Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault())) } /** * Copy from Kotatsu */ fun Collection.asArrayList(): ArrayList = if (this is ArrayList<*>) { this as ArrayList } else { ArrayList(this) } fun interface BufferedObserver { fun onChanged(t: T, prev: T?) } val Context.animatorDurationScale: Float get() = Settings.Global.getFloat( this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f ) // TODO: 完美的Insets @OptIn(ExperimentalLayoutApi::class) fun Modifier.withImeNavigationBarPadding() = composed { if (WindowInsets.isImeVisible) { Modifier .imePadding() .padding( bottom = WindowInsets.navigationBars .asPaddingValues() .calculateBottomPadding() + 16.dp ) } else { Modifier.navigationBarsPadding() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/GestureHelper.kt ================================================ package com.shicheeng.copymanga.util import android.content.Context import android.view.GestureDetector import android.view.MotionEvent import kotlin.math.roundToInt class GestureHelper(context: Context, private val listener: GestureListener) : GestureDetector.SimpleOnGestureListener() { private val detector = GestureDetector(context, this) private val width = context.resources.displayMetrics.widthPixels private val height = context.resources.displayMetrics.heightPixels private var isDispatching = false init { detector.setIsLongpressEnabled(true) detector.setOnDoubleTapListener(this) } fun dispatchTouchEvent(event: MotionEvent) { if (event.actionMasked == MotionEvent.ACTION_DOWN) { isDispatching = listener.onProcessTouch(event.rawX.toInt(), event.rawY.toInt()) } detector.onTouchEvent(event) } override fun onSingleTapConfirmed(e: MotionEvent): Boolean { if (!isDispatching) { return true } val xIndex = (e.rawX * 2f / width).roundToInt() val yIndex = (e.rawY * 2f / height).roundToInt() listener.onTouch( when (xIndex) { 0 -> AREA_LEFT 1 -> { when (yIndex) { 0 -> AREA_TOP 1 -> AREA_CENTER 2 -> AREA_BOTTOM else -> return false } } 2 -> AREA_RIGHT else -> return false }, ) return true } companion object { const val AREA_CENTER = 1 const val AREA_LEFT = 2 const val AREA_RIGHT = 3 const val AREA_TOP = 4 const val AREA_BOTTOM = 5 } interface GestureListener { fun onTouch(area: Int) fun onProcessTouch(rawX: Int, rawY: Int): Boolean } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/JsonObjectExtra.kt ================================================ package com.shicheeng.copymanga.util import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser val String.asJsonElementOrNull: JsonElement? get() { return if (this.isEmpty() || this.isNotBlank()) { null } else { val element = JsonParser.parseString(this) if (element.isJsonNull) null else element } } val JsonElement.asStringOrNull: String? get() { return if (this.isJsonNull) { null } else { this.asString } } fun JsonObject.getOr(member: String, other: () -> JsonElement): JsonElement { return if (this.has(member) && !this.get(member).isJsonNull) { this.get(member) } else { other() } } fun JsonObject.orEmptyJsonObject(): JsonObject = if (this.isEmpty) JsonObject() else this ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/KeyWordSwap.java ================================================ package com.shicheeng.copymanga.util; public class KeyWordSwap { public static final String DAY_RANK = "day"; public static final String WEEK_RANK = "week"; public static final String MONTH_RANK = "month"; public static final String TOTAL_RANK = "total"; public static final String FAKE_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36"; public static final String USER_AGENT_WORD = "User-Agent"; public static final String PATH_WORD_TYPE = "path_word"; public static final String MANGA_TITLE_TYPE = "manga_title"; public static final String CHAPTER_TYPE = "chapter"; public static final String CHAPTER_TYPE_FOR_BUNDLE = "chapter_type_in_bundle"; public static final String SAVED_LOCAL_CHAPTER_NAME = "DownloadChapters.json"; public static final String LOCAL_SAVABLE_INDEX_JSON = "index.json"; public static final int HANDLER_INFO_1_WHAT = 0x01; public static final String FLAG_ = "FLAG"; public static final String NON_JSON = "NON_JSON"; public static final String EXTRA_CANCEL_ID = "INTENT_KEY_CANCEL"; public static final String RECEIVER_CANCEL = "RECEIVER_CANCEL"; } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/OkhttpHelper.kt ================================================ package com.shicheeng.copymanga.util import androidx.compose.runtime.Immutable import com.shicheeng.copymanga.error.ContinuationCallCallback import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Response /** * Copy from [Kotatsu](https://github.com/KotatsuApp/Kotatsu). */ suspend fun Call.await(): Response = suspendCancellableCoroutine { val callback = ContinuationCallCallback(this, it) enqueue(callback) it.invokeOnCancellation(callback) } sealed class UIState { @Immutable data class Success(val content: T) : UIState() @Immutable data class Error(val errorMessage: E) : UIState() @Immutable object Loading : UIState() } sealed class LoginState { @Immutable data class Success(val content: T) : LoginState() @Immutable data class Error(val errorMessage: E) : LoginState() @Immutable object Loading : LoginState() @Immutable object NoStatus : LoginState() } sealed class SendUIState { @Immutable data class Success(val data: T) : SendUIState() @Immutable data class Error(val errorMessage: Throwable) : SendUIState() @Immutable object Loading : SendUIState() @Immutable object Idle : SendUIState() } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/ProcessLifecycle.kt ================================================ package com.shicheeng.copymanga.util import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope val processLifecycleScope: LifecycleCoroutineScope inline get() = ProcessLifecycleOwner.get().lifecycleScope ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/ReaderSliderAttach.kt ================================================ package com.shicheeng.copymanga.util import com.google.android.material.slider.Slider import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.viewmodel.ReaderViewModel private const val FRAGMENT_MAIN = "Main" class ReaderSliderAttach( private val callBack: PageSelectPosition, private val viewModel: ReaderViewModel, ) : Slider.OnChangeListener { override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { this moveTo value.toInt() } } fun attach(slider: Slider) { slider.addOnChangeListener(this) } private infix fun moveTo(position: Int) { val pages = viewModel.currentChapterPage val page = pages[position] callBack.onPositionCallBack(page) } } interface PageSelectPosition { fun onPositionCallBack(page: MangaReaderPage) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/RetainedLifecycleCoroutineScope.kt ================================================ package com.shicheeng.copymanga.util import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlin.coroutines.CoroutineContext class RetainedLifecycleCoroutineScope( val lifecycle: RetainedLifecycle, ) : CoroutineScope, RetainedLifecycle.OnClearedListener { override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate init { lifecycle.addOnClearedListener(this) } override fun onCleared() { coroutineContext.cancel() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/RetryableFlow.kt ================================================ package com.shicheeng.copymanga.util import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.onEach @OptIn(ExperimentalCoroutinesApi::class) @FlowPreview fun retryableFlow(retryTrigger: RetryTrigger, flowProvider: () -> Flow) = retryTrigger.retryEvent.filter { it == RetryTrigger.State.RETRYING } .flatMapConcat { flowProvider() } .onEach { retryTrigger.retryEvent.value = RetryTrigger.State.IDLE } class RetryTrigger { enum class State { RETRYING, IDLE } val retryEvent = MutableStateFlow(State.RETRYING) val retryTriggerFlow = retryEvent.asStateFlow() fun retry() { retryEvent.value = State.RETRYING } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/RunCatchingExtra.kt ================================================ package com.shicheeng.copymanga.util import kotlinx.coroutines.CancellationException inline fun T.runCatchingCancellable(block: T.() -> R): Result { return try { Result.success(block()) } catch (e: InterruptedException) { throw e } catch (e: CancellationException) { throw e } catch (e: Throwable) { Result.failure(e) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/SharedPreferenceExtra.kt ================================================ package com.shicheeng.copymanga.util import android.content.SharedPreferences import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn fun SharedPreferences.booleanFlow(key: String): Flow { return keyChanger() .filter { !it.isNullOrBlank() && it.isNotEmpty() } .filterNotNull() .filter { it == key } .map { getBoolean(key, false) } .conflate() } /** * 返回一个字串符,但是是[Flow]。 * 注意:如果需要使用[stateIn]方法最好加入初始时。 * * @param key KEY. */ fun SharedPreferences.stringFlow(key: String): Flow { return keyChanger() .filter { !it.isNullOrBlank() && it.isNotEmpty() } .filterNotNull() .filter { it == key } .map { getString(key, null) } .conflate() } fun SharedPreferences.integerFlow(key: String, def: Int): Flow { return keyChanger() .filter { !it.isNullOrBlank() && it.isNotEmpty() } .filterNotNull() .filter { it == key } .map { getInt(key, def) } .conflate() } private fun SharedPreferences.keyChanger(): Flow { return callbackFlow { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> trySend(key) } registerOnSharedPreferenceChangeListener(listener) awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/StateFlowExtra.kt ================================================ package com.shicheeng.copymanga.util import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch fun Flow.observe(owner: LifecycleOwner, collector: FlowCollector) { val start = if (this is StateFlow) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT owner.lifecycleScope.launch(start = start) { collect(collector) } } fun Flow.observe( owner: LifecycleOwner, minState: Lifecycle.State, collector: FlowCollector, ) { owner.lifecycleScope.launch { owner.lifecycle.repeatOnLifecycle(minState) { collect(collector) } } } fun Flow.transformPair(): Flow> = flow { var previous: T? = null collect { thing -> val result = previous to thing previous = thing emit(result) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/ThemeChanger.kt ================================================ package com.shicheeng.copymanga.util import androidx.appcompat.app.AppCompatDelegate enum class ThemeMode { LIGHT, DARK, SYSTEM; } fun setSystemNightMode(themeMode: ThemeMode) { AppCompatDelegate.setDefaultNightMode( when (themeMode) { ThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO ThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES ThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM }, ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/Throttler.kt ================================================ package com.shicheeng.copymanga.util import android.os.SystemClock class Throttler( private val timeoutMs: Long, ) { private var lastTick = 0L fun throttle(): Boolean { val now = SystemClock.elapsedRealtime() return if (lastTick + timeoutMs <= now) { lastTick = now true } else { false } } fun reset() { lastTick = 0L } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/ViewExtra.kt ================================================ package com.shicheeng.copymanga.util import androidx.viewpager2.widget.ViewPager2 inline fun ViewPager2.onPageChangeCallback(crossinline position: (Int) -> Unit) { registerOnPageChangeCallback( object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) position(position) } } ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/file/FileSequence.kt ================================================ package com.shicheeng.copymanga.util.file import com.shicheeng.copymanga.util.iterator.CloseableIterator import com.shicheeng.copymanga.util.iterator.MappingIterator import java.io.File import java.nio.file.Files import java.nio.file.Path class FileSequence(private val dir: File) : Sequence { override fun iterator(): Iterator { val stream = Files.newDirectoryStream(dir.toPath()) return CloseableIterator( MappingIterator(stream.iterator(), Path::toFile), stream ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/iterator/CloseableIterator.kt ================================================ package com.shicheeng.copymanga.util.iterator import okhttp3.internal.closeQuietly import java.io.Closeable class CloseableIterator( private val upstream: Iterator, private val closeable: Closeable, ) : Iterator, Closeable { private var isClose = false override fun hasNext(): Boolean { val result = upstream.hasNext() if (!result) { close() } return result } override fun next(): T { try { return upstream.next() } catch (e: NoSuchElementException) { close() throw e } } override fun close() { if (!isClose){ closeable.closeQuietly() isClose = true } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/iterator/MappingIterator.kt ================================================ package com.shicheeng.copymanga.util.iterator class MappingIterator( private val upstream: Iterator, private val mapper: (T) -> R, ) : Iterator { override fun hasNext(): Boolean { return upstream.hasNext() } override fun next(): R { return mapper(upstream.next()) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/util/progress/TimeLeftEstimator.kt ================================================ package com.shicheeng.copymanga.util.progress import android.os.SystemClock import java.util.concurrent.TimeUnit import kotlin.math.roundToInt import kotlin.math.roundToLong private const val MIN_ESTIMATE_TICKS = 4 private const val NO_TIME = -1L class TimeLeftEstimator { private var times = ArrayList() private var lastTick: Tick? = null private val tooLargeTime = TimeUnit.DAYS.toMillis(1) fun tick(value: Int, total: Int) { if (total < 0) { emptyTick() return } if (lastTick?.value == value) { return } val tick = Tick(value, total, SystemClock.elapsedRealtime()) lastTick?.let { val ticksCount = value - it.value times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt()) } lastTick = tick } fun emptyTick() { lastTick = null } fun getEstimatedTimeLeft(): Long { val progress = lastTick ?: return NO_TIME if (times.size < MIN_ESTIMATE_TICKS) { return NO_TIME } val timePerTick = times.average() val ticksLeft = progress.total - progress.value val eta = (ticksLeft * timePerTick).roundToLong() return if (eta < tooLargeTime) eta else NO_TIME } fun getEta(): Long { val etl = getEstimatedTimeLeft() return if (etl == NO_TIME) NO_TIME else System.currentTimeMillis() + etl } private class Tick( @JvmField val value: Int, @JvmField val total: Int, @JvmField val time: Long, ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/ExpandSelectionBar.kt ================================================ package com.shicheeng.copymanga.view import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.AdapterView import android.widget.LinearLayout import androidx.appcompat.widget.ListPopupWindow import com.shicheeng.copymanga.R import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.databinding.MaganSelectBarBinding class ExpandSelectionBar(context: Context, attributeSet: AttributeSet) : LinearLayout(context, attributeSet, 0, 0) { private val itemView = View.inflate(context, R.layout.magan_select_bar, this) private val binding = MaganSelectBarBinding.bind(itemView) private val listPopupWindowSort = ListPopupWindow( context, null, androidx.appcompat.R.attr.listPopupWindowStyle ) private var _onClick: ((MangaSortBean?) -> Unit)? = null var menuList: List? = null set(value) { if (value != null) { field = value } } fun setOnItemClickListener(onClickListener: (MangaSortBean?) -> Unit) { this._onClick = onClickListener } var tipText: CharSequence get() = binding.selectBarText.text set(value) { binding.selectBarText.text = value } var autoCompleteText: CharSequence? = binding.auto2.text.toString() set(value) { if (value != null) { field = value binding.auto2.setText(value) } } init { listPopupWindowSort.anchorView = binding.selectBarExpandMenu binding.auto2.setOnClickListener { listPopupWindowSort.show() } listPopupWindowSort.setOnItemClickListener { _: AdapterView<*>, _: View, i: Int, _: Long -> val name = menuList?.get(i)?.pathName _onClick?.invoke(menuList?.get(i)) binding.auto2.setText(name) listPopupWindowSort.dismiss() } context.theme.obtainStyledAttributes( attributeSet, R.styleable.ExpandSelectionBar, 0, 0 ).apply { try { binding.selectBarText.text = getString(R.styleable.ExpandSelectionBar_tipText) binding.selectBarExpandMenu.hint = getString(R.styleable.ExpandSelectionBar_hintText) } finally { recycle() } } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/HeadLineView.java ================================================ package com.shicheeng.copymanga.view; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.view.View; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.Nullable; import com.shicheeng.copymanga.R; public class HeadLineView extends LinearLayout { private TextView handLineText; private LinearLayout linearLayout; private onHeadClickListener onHeadClickListener; private void initView(Context context){ View.inflate(context, R.layout.manga_headline_1,this); handLineText = findViewById(R.id.title_id); linearLayout = findViewById(R.id.linear_id); linearLayout.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { onHeadClickListener.onClick(view); } }); } public HeadLineView(Context context) { super(context); initView(context); } public HeadLineView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); initView(context); @SuppressLint("Recycle") TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.HeadLineView); String title = array.getString(R.styleable.HeadLineView_headTitle); setHandLineText(title); } public void setHandLineText(int redID){ handLineText.setText(redID); } public void setHandLineText(String text){ handLineText.setText(text); } public String getHandLineText(){ return handLineText.getText().toString(); } public void setOnHeadClickListener(HeadLineView.onHeadClickListener onHeadClickListener) { this.onHeadClickListener = onHeadClickListener; } public interface onHeadClickListener{ void onClick(View view); } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/MyRecyclerView.kt ================================================ package com.shicheeng.copymanga.view import android.content.Context import android.util.AttributeSet import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.shicheeng.copymanga.util.FirstSnapHelper class MyRecyclerView @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, ) : RecyclerView(context, attributeSet) { private val layout = LinearLayoutManager(context, HORIZONTAL, false) private val spanHelper = FirstSnapHelper() init { layoutManager = layout spanHelper.attachToRecyclerView(this) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/SummaryText.kt ================================================ package com.shicheeng.copymanga.view import android.animation.AnimatorSet import android.animation.ValueAnimator import android.content.Context import android.text.TextUtils import android.util.AttributeSet import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.doOnNextLayout import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.shicheeng.copymanga.util.animatorDurationScale import kotlin.math.roundToLong /** * 修改自Tachiyomi。 * * 展开文字控件 */ class SummaryText @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0, ) : AppCompatTextView(context, attrs, defStyleAttr) { var expanded = false set(value) { if (field != value) { field = value updateExpandState() } } private var recalculateHeights = false private var descExpandedHeight = -1 private var descShrunkHeight = -1 private var animatorSet: AnimatorSet? = null private fun updateExpandState() { val initialSetup = maxHeight < 0 val maxHeightTarget = if (expanded) descExpandedHeight else descShrunkHeight val maxHeightStart = if (initialSetup) maxHeightTarget else maxHeight val descMaxHeightAnimator = ValueAnimator().apply { setIntValues(maxHeightStart, maxHeightTarget) addUpdateListener { maxHeight = it.animatedValue as Int } } var pastHalf = false val toggleTarget = if (expanded) 1F else 0F val toggleStart = if (initialSetup) { toggleTarget } else { translationY / height } val toggleAnimator = ValueAnimator().apply { setFloatValues(toggleStart, toggleTarget) addUpdateListener { // Update non-animatable objects mid-animation makes it feel less abrupt if (it.animatedFraction >= 0.5F && !pastHalf) { pastHalf = true ellipsizeWhenNeeded() } } } animatorSet?.cancel() animatorSet = AnimatorSet().apply { interpolator = FastOutSlowInInterpolator() duration = (TOGGLE_ANIM_DURATION * context.animatorDurationScale).roundToLong() playTogether(toggleAnimator, descMaxHeightAnimator) start() } } private fun ellipsizeWhenNeeded() { return if (!expanded) { ellipsize = TextUtils.TruncateAt.END } else { ellipsize = null } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // Wait until parent view has determined the exact width // because this affect the description line count val measureWidthFreely = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY if (!recalculateHeights || measureWidthFreely) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) return } recalculateHeights = false // Measure with expanded lines maxLines = Int.MAX_VALUE super.onMeasure(widthMeasureSpec, heightMeasureSpec) descExpandedHeight = measuredHeight // Measure with shrunk lines maxLines = SHRUNK_DESC_MAX_LINES super.onMeasure(widthMeasureSpec, heightMeasureSpec) descShrunkHeight = measuredHeight } init { recalculateHeights = true doOnNextLayout { updateExpandState() } if (!isInLayout) { requestLayout() } minLines = DESC_MIN_LINES setOnClickListener { expanded = !expanded } } } private const val TOGGLE_ANIM_DURATION = 300L private const val DESC_MIN_LINES = 2 private const val SHRUNK_DESC_MAX_LINES = 3 ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/TransitionTextview.kt ================================================ package com.shicheeng.copymanga.view import android.content.Context import android.util.AttributeSet import android.view.Gravity import android.view.ViewGroup import androidx.annotation.StringRes import androidx.core.view.isVisible import androidx.transition.Fade import androidx.transition.Slide import androidx.transition.TransitionManager import androidx.transition.TransitionSet import com.google.android.material.textview.MaterialTextView class TransitionTextview @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = 0, ) : MaterialTextView(context, attributeSet, defStyleAttr) { private val hideRunnable = Runnable { hide() } fun show(message: CharSequence) { removeCallbacks(hideRunnable) text = message setupTransition() isVisible = true } fun show(@StringRes resID: Int) { show(context.getString(resID)) } fun tip(message: CharSequence, duration: Long) { show(message) postDelayed(hideRunnable, duration) } override fun onDetachedFromWindow() { removeCallbacks(hideRunnable) super.onDetachedFromWindow() } fun hide() { removeCallbacks(hideRunnable) setupTransition() isVisible = false } private fun setupTransition() { val parentView = parent as? ViewGroup ?: return val transition = TransitionSet() .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTarget(this) .addTransition(Slide(Gravity.TOP)) .addTransition(Fade()) TransitionManager.beginDelayedTransition(parentView, transition) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/control/ReaderControl.kt ================================================ package com.shicheeng.copymanga.view.control import android.view.SoundEffectConstants import android.view.View import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.GestureHelper as GridTouchHelper class ReaderControl( private val listener: ControlDelegateListener, private val settingPref: SettingPref, ) { private val isQuickTouchEnable get() = settingPref.hyperTouch.value fun onGridTouch(area: Int, view: View) { when (area) { GridTouchHelper.AREA_CENTER -> { listener.hide() view.playSoundEffect(SoundEffectConstants.CLICK) } GridTouchHelper.AREA_TOP -> if (isQuickTouchEnable) { listener.scrollPage(-1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP) } GridTouchHelper.AREA_LEFT -> { listener.scrollPage(if (isReaderTapsReversed()) -1 else 1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT) } GridTouchHelper.AREA_BOTTOM -> if (isQuickTouchEnable) { listener.scrollPage(1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN) } GridTouchHelper.AREA_RIGHT -> { listener.scrollPage(if (isReaderTapsReversed()) 1 else -1) view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT) } } } private fun isReaderTapsReversed(): Boolean { return listener.readerMode == ReaderMode.STANDARD || listener.readerMode == ReaderMode.WEBTOON } interface ControlDelegateListener { val readerMode: ReaderMode? fun scrollPage(delta: Int) fun hide() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/view/list/SpaceItem.kt ================================================ package com.shicheeng.copymanga.view.list import android.graphics.Rect import android.view.View import androidx.annotation.Px import androidx.recyclerview.widget.RecyclerView class SpaceItem(@Px private val spacingDp: Int) : RecyclerView.ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { outRect.set(spacingDp, spacingDp, spacingDp, spacingDp) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/AuthorMangaViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.shicheeng.copymanga.resposity.AuthorsMangaRepository import com.shicheeng.copymanga.resposity.logD import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import javax.inject.Inject @HiltViewModel class AuthorMangaViewModel @Inject constructor( savedStateHandle: SavedStateHandle, authorsMangaRepository: AuthorsMangaRepository, ) : ViewModel() { private val authorPathWordNow: String? = savedStateHandle["author_path_word"] private val _authorPathWord = MutableStateFlow(authorPathWordNow) @OptIn(ExperimentalCoroutinesApi::class) val list = _authorPathWord .filter { !it.isNullOrBlank() && it.isNotEmpty() }.filterNotNull() .flatMapLatest { it.logD() authorsMangaRepository.fetchMangaByPathWord(it) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/CommentViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.shicheeng.copymanga.resposity.ComicCommentRepository import com.shicheeng.copymanga.resposity.LoginTokenRepository import com.shicheeng.copymanga.util.RetryTrigger import com.shicheeng.copymanga.util.SendUIState import com.shicheeng.copymanga.util.retryableFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class CommentViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: ComicCommentRepository, private val loginTokenRepository: LoginTokenRepository, ) : ViewModel() { private val uuid: String? = savedStateHandle["uuid_comic"] private val _comicUUID = MutableStateFlow(uuid) private val _comicText = MutableStateFlow("") val loginIsExpired = loginTokenRepository.isExpiredFlow .filterNotNull() .stateIn( scope = viewModelScope, initialValue = loginTokenRepository.isExpired, started = SharingStarted.Eagerly ) val retry = RetryTrigger() private val commentPushEvent = combine(_comicUUID, _comicText) { uuid, text -> if (uuid != null) CombineComicDataModel(uuid, text) else null }.filterNotNull().filter { it.ensureTextNoNull() }.flatMapLatest { repository.push(it.uuid, it.text) } @OptIn(FlowPreview::class) val commentPush = retryableFlow(retry) { commentPushEvent }.stateIn( scope = viewModelScope, initialValue = SendUIState.Idle, started = SharingStarted.Eagerly ) val comments = _comicUUID .filter { !it.isNullOrBlank() && it.isNotEmpty() } .filterNotNull() .flatMapLatest { repository.loadComment(it) }.cachedIn(viewModelScope) /** * 发送消息 * @param text 消息文本 * @author ShihCheeng */ fun sendComment(text: String) = viewModelScope.launch { _comicText.emit(text) } data class CombineComicDataModel( val uuid: String, val text: String, ) { fun ensureTextNoNull() = uuid.isNotEmpty() && uuid.isNotBlank() && text.isNotBlank() && text.isNotEmpty() } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/DownloadViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.domin.DownloadFileDetectUtil import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class DownloadViewModel @Inject constructor( fileDetectUtil: DownloadFileDetectUtil, ) : ViewModel() { val list = fileDetectUtil.findDownloadManga() .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/ExploreMangaViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.compose.material3.ExperimentalMaterial3Api import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.shicheeng.copymanga.data.MangaSortBean import com.shicheeng.copymanga.data.finished.Item import com.shicheeng.copymanga.json.MangaSortJson import com.shicheeng.copymanga.resposity.MangaFilterRepository import com.shicheeng.copymanga.resposity.logD import com.shicheeng.copymanga.util.UIState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ExploreMangaViewModel @Inject constructor( private val repository: MangaFilterRepository, ) : ViewModel() { private val _uiState = MutableStateFlow>>(UIState.Loading) val uiState = _uiState.asStateFlow() val order = MutableStateFlow(null) val themeType = MutableStateFlow(null) val top = MutableStateFlow(null) val loadFilterResult: Flow> = combine(order, themeType, top) { t1, t2, t3 -> FilterKeyModel(order = t1, theme = t2, top = t3) }.flatMapLatest { repository.filterMangas(top = it.top, theme = it.theme, ordering = it.order) }.cachedIn(viewModelScope) private val _showBottomSheet = MutableStateFlow(MangaSortJson.ORDER) val showBottomSheet = _showBottomSheet.asStateFlow() init { loadData() } fun loadData() = viewModelScope.launch { _uiState.emit(UIState.Loading) try { val listTheme = repository.theme() _uiState.emit(UIState.Success(listTheme)) } catch (e: Exception) { e.printStackTrace() _uiState.emit(UIState.Error(e)) } } fun filterOn( order: String? = null, theme: String? = null, top: String? = null, ) = viewModelScope.launch { this@ExploreMangaViewModel.order.emit(order) this@ExploreMangaViewModel.themeType.emit(theme) this@ExploreMangaViewModel.top.emit(top) } fun showThemeFilterList() { _showBottomSheet.tryEmit(MangaSortJson.THEME) } fun showTopFilterList() { _showBottomSheet.tryEmit(MangaSortJson.PATH) } fun showOrderFilterList() { _showBottomSheet.tryEmit(MangaSortJson.ORDER) } data class FilterKeyModel( val order: String?, val theme: String?, val top: String?, ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/HistoryViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.resposity.MangaHistoryRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HistoryViewModel @Inject constructor( private val history: MangaHistoryRepository, ) : ViewModel() { val historyList = history.allHistoryDao.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) fun deleteHistory(mangaHistoryDataModel: MangaHistoryDataModel) = viewModelScope.launch { history.deleteSingleHistory(mangaHistoryDataModel) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/HomeViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.data.MainPageDataModel import com.shicheeng.copymanga.resposity.MangaMainPageRepository import com.shicheeng.copymanga.util.UIState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val mangaMainPageRepository: MangaMainPageRepository, ) : ViewModel() { private val _uiState = MutableStateFlow>(UIState.Loading) val uiState = _uiState.asStateFlow() init { loadData() } fun loadData() = viewModelScope.launch { _uiState.emit(UIState.Loading) try { val mainData = mangaMainPageRepository.fetchMainData() Log.d("TAG", "loadData: $mainData") _uiState.emit(UIState.Success(mainData)) } catch (e: Exception) { e.printStackTrace() _uiState.emit(UIState.Error(e)) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/LoginPersonalListViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.data.login.LocalLoginDataModel import com.shicheeng.copymanga.resposity.LoginRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LoginPersonalListViewModel @Inject constructor( private val repository: LoginRepository, ) : ViewModel() { val personalList = repository .getAllLoginInstance() .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) fun delete(localLoginDataModel: LocalLoginDataModel) = viewModelScope.launch { repository.deleteOneInstance(localLoginDataModel) } fun selectUUId(uuid: String) = viewModelScope.launch { repository.selectOne(uuid) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/MainViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.resposity.LoginRepository import com.shicheeng.copymanga.util.collectRepeatLifecycle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( loginRepository: LoginRepository, ) : ViewModel() { val loginInfoStatus = loginRepository.testLoginStatus() .distinctUntilChanged() .conflate() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = null ) val showSnackBar = MutableStateFlow(false) init { viewModelScope.launch { loginInfoStatus.collectLatest { showSnackBar.emit(it != null) } } } fun dismissShack() = viewModelScope.launch { showSnackBar.emit(false) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/MangaHotListViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.shicheeng.copymanga.pagingsource.HotPagingSource import com.shicheeng.copymanga.resposity.MangaHotRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class MangaHotListViewModel @Inject constructor( repository: MangaHotRepository, ) : ViewModel() { val hotMangaList = Pager(config = PagingConfig(pageSize = 21)) { HotPagingSource(repository) }.flow.cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/MangaInfoViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.resposity.MangaInfoRepository import com.shicheeng.copymanga.server.download.woker.DownloadedWorker import com.shicheeng.copymanga.ui.screen.setting.SettingPref import com.shicheeng.copymanga.util.RetryTrigger import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.retryableFlow import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MangaInfoViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: MangaHistoryRepository, private val infoRepository: MangaInfoRepository, private val setting: SettingPref, private val downloadedWorker: DownloadedWorker.Caller, ) : ViewModel() { val pathWord: String = savedStateHandle["path_word"] ?: error("无关键词") private val _historyFlowChapter = repository.fetchMangaChapterByPathWordFlow(pathWord) //Chapter and Information private val _chapter = MutableStateFlow>>(UIState.Loading) val chapters = _chapter.asStateFlow() private val _mangaInfo = MutableStateFlow>(UIState.Loading) val mangaInfo = _mangaInfo.asStateFlow() private val _selectedChapter = MutableStateFlow>(emptyList()) val selectChapter = _selectedChapter.asStateFlow() val lastWatchChapter = combine( flow = _chapter, flow2 = _mangaInfo ) { uiStateChapter: UIState>, uiStateInfo: UIState -> when { uiStateChapter is UIState.Success && uiStateInfo is UIState.Success -> { if (uiStateChapter.content.isNotEmpty()) { uiStateChapter.content[uiStateInfo.content.positionChapter] } else null } else -> { null } } }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = null ) private val collectRetryTrigger = RetryTrigger() @OptIn(FlowPreview::class) val lastWebLookedChapter = retryableFlow(retryTrigger = collectRetryTrigger) { infoRepository.fetchComicWebHistory(pathWord) }.stateIn( started = SharingStarted.Eagerly, initialValue = null, scope = viewModelScope ) init { onInfoLoad() onChapterLoad() viewModelScope.launch { repository.fetchMangaChapterByPathWordFlow(pathWord).collectLatest { it?.let { _chapter.emit(UIState.Success(it)) } } } } fun onInfoLoad() = viewModelScope.launch { _chapter.emit(UIState.Loading) try { val mangaInfoContent = infoRepository.fetchMangaInfo(pathWord) _mangaInfo.emit(UIState.Success(mangaInfoContent)) } catch (e: Exception) { e.printStackTrace() _mangaInfo.emit(UIState.Error(e)) } } fun selectItem(item: LocalChapter, isAdd: Boolean) = viewModelScope.launch { _selectedChapter.update { if (isAdd) { it.plus(item) } else { it.minus(item) } } } fun deselectedAllItem() { _selectedChapter.update { emptyList() } } fun selectFirst5(): List? { return if (chapters.value is UIState.Success) { (chapters.value as UIState.Success).content.take(5) } else null } fun selectLast5(): List? { return if (chapters.value is UIState.Success) { (chapters.value as UIState.Success).content.takeLast(5) } else null } private fun onChapterLoad() = viewModelScope.launch { try { _chapter.emit(UIState.Loading) val mangaChapter = infoRepository.fetchMangaChapters(pathWord) _chapter.emit(UIState.Success(mangaChapter)) } catch (e: Exception) { e.printStackTrace() _chapter.emit(UIState.Error(e)) } } fun chapterLoadForce() = viewModelScope.launch { try { _chapter.emit(UIState.Loading) _mangaInfo.emit(UIState.Success(infoRepository.fetchMangaInfoForce(pathWord))) _chapter.emit(UIState.Success(infoRepository.fetchMangaChaptersForce(pathWord))) } catch (e: Exception) { e.printStackTrace() _chapter.emit(UIState.Error(e)) } } fun comicUpdate(enable: Boolean) = viewModelScope.launch { repository.getHistoryByMangaPathWord(pathWord)?.let { val newData = it.copy(isSubscribe = enable) repository.update(newData) _mangaInfo.emit(UIState.Success(newData)) } } fun comicMarkRead(isRead: Boolean) = viewModelScope.launch(Dispatchers.IO) { _selectedChapter.collectLatest { localChapters -> localChapters.map { it.copy( isReadFinish = isRead, readIndex = if (isRead) it.readIndex else 0 ) }.let { repository.updateLocalChapter(it) } } } fun comicAddWebLib(mangaUUID: String, add: Boolean) = viewModelScope.launch { infoRepository.collect(mangaUUID, add) collectRetryTrigger.retry() } fun enableComicUpdate(enable: Boolean) { setting.enableComicsUpdateFetch(enable) } fun downloadManga(chapters: Array) = viewModelScope.launch { downloadedWorker.download(pathWord, chapters) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/MangaNewestListViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.shicheeng.copymanga.pagingsource.NewestPagingSource import com.shicheeng.copymanga.resposity.MangaNewestRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class MangaNewestListViewModel @Inject constructor( newestRepository: MangaNewestRepository, ) : ViewModel() { val list = Pager( config = PagingConfig(pageSize = 21), ) { NewestPagingSource(newestRepository) }.flow.cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/MangaRecommendListViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.shicheeng.copymanga.pagingsource.RecommendPagingSource import com.shicheeng.copymanga.resposity.MangaRecommendRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class MangaRecommendListViewModel @Inject constructor( repository: MangaRecommendRepository, ) : ViewModel() { val recommendMangaList = Pager(config = PagingConfig(pageSize = 21)) { RecommendPagingSource(repository) }.flow.cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/PersonalDetailViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.resposity.LoginDetailRepository import com.shicheeng.copymanga.util.RetryTrigger import com.shicheeng.copymanga.util.UIState import com.shicheeng.copymanga.util.retryableFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class PersonalDetailViewModel @Inject constructor( private val loginDetailRepository: LoginDetailRepository, ) : ViewModel() { private val retryTrigger = RetryTrigger() @OptIn(FlowPreview::class) val data = retryableFlow(retryTrigger) { loginDetailRepository.detail() .onStart { UIState.Loading } }.stateIn( scope = viewModelScope, initialValue = UIState.Loading, started = SharingStarted.Eagerly ) fun retry() = retryTrigger.retry() } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/PersonalViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.resposity.LoginRepository import com.shicheeng.copymanga.ui.screen.setting.SettingPref import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class PersonalViewModel @Inject constructor( private val loginRepository: LoginRepository, settingPref: SettingPref, ) : ViewModel() { @OptIn(ExperimentalCoroutinesApi::class) val user = settingPref.loginPersonalFlow .stateIn( scope = viewModelScope, initialValue = settingPref.loginPerson, started = SharingStarted.Eagerly ) .filter { !it.isNullOrBlank() && it.isNotEmpty() } .filterNotNull() .flatMapLatest { loginRepository.getUserByUUid(it) }.stateIn( scope = viewModelScope, initialValue = null, started = SharingStarted.Eagerly ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/RankViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.shicheeng.copymanga.resposity.MangaRankRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class RankViewModel @Inject constructor( rankRepository: MangaRankRepository, ) : ViewModel() { val dayRank = rankRepository.fetchMangaRank("day").cachedIn(viewModelScope) val weekRank = rankRepository.fetchMangaRank("week").cachedIn(viewModelScope) val monthRank = rankRepository.fetchMangaRank("month").cachedIn(viewModelScope) val totalRank = rankRepository.fetchMangaRank("total").cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/ReaderViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.data.MangaHistoryDataModel import com.shicheeng.copymanga.data.MangaReaderPage import com.shicheeng.copymanga.data.MangaState import com.shicheeng.copymanga.data.ReaderContent import com.shicheeng.copymanga.data.ReaderState import com.shicheeng.copymanga.data.local.LocalChapter import com.shicheeng.copymanga.data.local.toMangaState import com.shicheeng.copymanga.fm.domain.ChapterLoader import com.shicheeng.copymanga.fm.domain.PagerLoader import com.shicheeng.copymanga.fm.reader.MangaLoader import com.shicheeng.copymanga.fm.reader.ReaderMode import com.shicheeng.copymanga.resposity.MangaHistoryRepository import com.shicheeng.copymanga.resposity.MangaInfoRepository import com.shicheeng.copymanga.ui.screen.setting.SettingPref import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject import kotlin.collections.set import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext private const val MAX_LOAD_PAGER = 4 /** * 重新设计ReaderViewModel */ @HiltViewModel class ReaderViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: MangaHistoryRepository, private val pagerLoader: PagerLoader, private val settingPref: SettingPref, private val mangaInfoRepository: MangaInfoRepository, private val chapterLoader: ChapterLoader, ) : ViewModel() { private val mangaLoader = MangaLoader(savedStateHandle) private val currentPathWord = mangaLoader.mangaPathWord private val currentChapterUUID = mangaLoader.mangaChapterUUID private val list by lazy { runBlocking { mangaInfoRepository.fetchMangaChapters( pathWord = requireNotNull(mangaLoader.mangaPathWord), ) } } private val initChapter = list.find { x -> x.uuid == currentChapterUUID } ?: list[0] private var loadJob: Job? = null private val chapters: LinkedHashMap get() = chapterLoader.chapters private val _loadingCounter = MutableStateFlow(false) val loadingCounter get() = _loadingCounter.asStateFlow() private val _errorHandler = MutableStateFlow(null) val errorHandler get() = _errorHandler.asStateFlow() val information = MutableStateFlow(null) private val historyData = MutableStateFlow(null) val state = MutableStateFlow(initChapter.toMangaState()) val mangaContent = MutableStateFlow(ReaderContent(emptyList(), null)) val readerModel = MutableStateFlow(null) val nextChapterLoadStateFlow = chapterLoader.nextChapterLoadingState.asStateFlow() init { loadImp() } fun retry() { loadJob?.cancel() loadImp() } private fun loadPrevNextChapter(uuid: String?, isNext: Boolean) { loadJob = loadJop(Dispatchers.Default) { chapterLoader.loadPrevNextChapter(list, uuid, isNext) mangaContent.value = ReaderContent(chapterLoader.snapshot(), null) } } private fun loadHistory(pathWord: String) = viewModelScope.launch { val historyDataModel = repository.getHistoryByMangaPathWord(pathWord) historyData.emit(historyDataModel) } fun getCurrentReaderState() = state.value val currentChapterPage: List get() { val id = state.value.uuid return chapterLoader.getPage(id) } fun onPagePositionChange(position: Int) { val pages = mangaContent.value.list pages.getOrNull(position)?.let { state.update { mangaState -> mangaState.copy(uuid = it.uuid ?: return, page = it.index) } } onInfoChange() if (pages.isEmpty() || loadJob?.isActive == true) { return } if (position <= MAX_LOAD_PAGER) { loadPrevNextChapter(pages.first().uuid, isNext = false) } if (position >= pages.size - MAX_LOAD_PAGER) { loadPrevNextChapter(pages.last().uuid, isNext = true) } } @WorkerThread private fun onInfoChange() { val state = getCurrentReaderState() val chapter = state.uuid.let(chapters::get) val positionChapter = list.indexOfFirst { it.uuid == state.uuid } val readerState = ReaderState( chapterName = chapter?.name, subTime = chapter?.datetime_created, uuid = chapter?.uuid, totalPage = if (chapter == null) 0 else chapterLoader[chapter.uuid], currentPage = state.page, chapterPosition = positionChapter, mangaName = historyData.value?.name ) information.value = readerState viewModelScope.launch { val newHistoryData = historyData.value ?.copy( positionPage = state.page, positionChapter = positionChapter, time = System.currentTimeMillis() ) if (newHistoryData != null) { repository.updateAsync(newHistoryData) } } } fun saveCurrentState(nowState: MangaState? = null) { if (nowState != null) { state.value = nowState } } fun switchMode(readerMode: ReaderMode) = viewModelScope.launch { readerModel.value = readerMode mangaContent.value.run { mangaContent.value = copy(state = getCurrentReaderState()) } val renew = historyData.value?.copy(readerModeId = readerMode.id) ?: return@launch repository.update(renew) historyData.emit(renew) } fun switchChapter(uuid: String?) { if (uuid != null) { val prevJob = loadJob loadJob = loadJop(Dispatchers.Default) { prevJob?.cancelAndJoin() mangaContent.value = ReaderContent(emptyList(), null) chapterLoader.loadSingleChapter(initChapter.comicPathWord, uuid) mangaContent.value = ReaderContent(chapterLoader.snapshot(), MangaState(uuid, 0)) } } } fun loadNextPrvChapter(uuid: String?, isNext: Boolean) { if (uuid != null) { val prevJob = loadJob loadJob = loadJop(Dispatchers.Default) { prevJob?.cancelAndJoin() mangaContent.value = ReaderContent(emptyList(), null) val predicate: (LocalChapter) -> Boolean = { it.uuid == uuid } val index = if (isNext) { list.indexOfFirst(predicate) } else { list.indexOfLast(predicate) } if (index == -1) return@loadJop val newChapter = list.getOrNull(if (isNext) index + 1 else index - 1) ?: return@loadJop switchChapter(newChapter.uuid) mangaContent.value = ReaderContent(chapterLoader.snapshot(), MangaState(uuid, 0)) } } } fun saveLocalChapterState(int: Int) { viewModelScope.launch { val currentChapter = list.find { it.uuid == getCurrentReaderState().uuid } ?: return@launch val newChapterTemp = currentChapter.copy( readIndex = int, isReadProgress = int != 0, isReadFinish = int == (currentChapter.size - 1) ) repository.updateLocalChapter(newChapterTemp) } } private fun loadImp() { loadJob = loadJop(Dispatchers.Default) { list.forEach { chapters[it.uuid] = it } loadHistory(initChapter.comicPathWord) val mode = detectReaderMode() readerModel.emit(mode) chapterLoader.loadSingleChapter(initChapter.comicPathWord, state.value.uuid) onInfoChange() mangaContent.emit(ReaderContent(chapterLoader.snapshot(), state.value)) } } /** * 检测漫画模式 */ private suspend fun detectReaderMode(): ReaderMode { val modeId = repository.getHistoryByMangaPathWord( currentPathWord )?.readerModeId return ReaderMode.idOf(modeId) ?: ReaderMode.valueOf(settingPref.readerMode) } private fun loadJop( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, ) = viewModelScope.launch(context + createErrorHandler(), start) { _loadingCounter.emit(true) try { block() } finally { _loadingCounter.emit(false) } } private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable -> throwable.printStackTrace() if (throwable !is CancellationException) { _errorHandler.tryEmit(throwable) } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/RootViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.json.UpdateMetaDataJson import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RootViewModel @Inject constructor( private val updateMetaDataJson: UpdateMetaDataJson, ) : ViewModel() { val updateData = updateMetaDataJson.availableUpdateVersion() init { viewModelScope.launch { updateMetaDataJson.fetchUpdate() } } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/SearchResultViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.shicheeng.copymanga.data.search.SearchResultDataModel import com.shicheeng.copymanga.resposity.MangaSearchRepository import com.shicheeng.copymanga.resposity.logD import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SearchResultViewModel @Inject constructor( private val repository: MangaSearchRepository, ) : ViewModel() { private val mutableQueryString = MutableStateFlow("") @OptIn(ExperimentalCoroutinesApi::class) val searchResult: Flow> = mutableQueryString.flatMapLatest { repository.fetchSearchResult(it) }.cachedIn(viewModelScope) fun loadSearch(word: String) = viewModelScope.launch { mutableQueryString.emit(word) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/SearchViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.data.searchhistory.SearchHistory import com.shicheeng.copymanga.resposity.MangaHistoryRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val historyRepository: MangaHistoryRepository, ) : ViewModel() { private var _searchWord = MutableStateFlow("") @OptIn(ExperimentalCoroutinesApi::class) val searchedHistoryWord = combine( _searchWord, historyRepository.historySearchedWord() ) { s, list -> if (s.isBlank()) { list } else { list.filter { x -> x.word.contains(s) } } }.mapLatest { it.map { x -> x.word } }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) fun upWord(wd: String) { _searchWord.tryEmit(wd) } fun saveSearchWord(word: String) = viewModelScope.launch { historyRepository.upsertSearchWord( SearchHistory( word = word, time = System.currentTimeMillis() ) ) } } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/SubscribedViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shicheeng.copymanga.resposity.MangaHistoryRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class SubscribedViewModel @Inject constructor( private val repository: MangaHistoryRepository, ) : ViewModel() { val data = repository.allHistoryDao.map { mangaHistoryDataModels -> mangaHistoryDataModels.filter { it.isSubscribe } }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = emptyList() ) } ================================================ FILE: app/src/main/java/com/shicheeng/copymanga/viewmodel/WebShelfViewModel.kt ================================================ package com.shicheeng.copymanga.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.shicheeng.copymanga.resposity.WebShelfRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class WebShelfViewModel @Inject constructor( webShelfRepository: WebShelfRepository, ) : ViewModel() { val data = webShelfRepository .loadWebShelf() .cachedIn( scope = viewModelScope ) } ================================================ FILE: app/src/main/res/drawable/apache_svgrepo_com.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_cached_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_close_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_comment_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_content_cut_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_delete_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_delete_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_done_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_explore_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_format_list_bulleted_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_history_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_insert_chart_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_insert_chart_outlined_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_library_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_library_add_check_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_pause_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_person_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_play_arrow_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_remove_done_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_replay_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_rss_feed_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_security_update_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_send_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_visibility_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_visibility_off_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_warning_amber_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_webhook_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_avd.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_hot.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_loop.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_region.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_done_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_home_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_page.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_skip_next_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_skip_previous_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/iconmonstr_github_5.xml ================================================ ================================================ FILE: app/src/main/res/drawable/iconmonstr_rss_feed_baseline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/iconmonstr_rss_feed_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/legal_license_mit_svgrepo_com.xml ================================================ ================================================ FILE: app/src/main/res/drawable/open_source_fill_svgrepo_com.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_auto_mode_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_cell_wifi_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_chrome_reader_mode_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_clean_hands_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_cleaning_services_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_cloud_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_comment_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_contrast_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_do_not_disturb_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_download_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_download_for_offline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_expand_circle_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_file_download_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_input_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_library_books_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_switch_access_shortcut_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_timer_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_touch_app_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_update_disabled_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_wifi_lock_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_work_history_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/transition_text_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/undraw_arrow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/undraw_drink_coffee.xml ================================================ ================================================ FILE: app/src/main/res/drawable/undraw_login_re.xml ================================================ ================================================ FILE: app/src/main/res/drawable/undraw_no_data_re_kwbl.xml ================================================ ================================================ FILE: app/src/main/res/drawable/undraw_personal_file_re.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_arrow_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_explore_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_look_more.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_manga_info_main.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_manga_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_person_center.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_setting_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_swith_horiz.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_swith_vert.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi/ic_trend_up.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_manga_reader.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_dialog_download_info.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_reader_normal.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_reader_webtoon.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_last_page.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_page.xml ================================================ ================================================ FILE: app/src/main/res/layout/item_page_webtoon.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_error.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_image_load.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_setting_wraning_region.xml ================================================ ================================================ FILE: app/src/main/res/layout/layout_widget_switch_pref.xml ================================================ ================================================ FILE: app/src/main/res/layout/magan_select_bar.xml ================================================ ================================================ FILE: app/src/main/res/layout/manga_headline_1.xml ================================================ ================================================ FILE: app/src/main/res/layout/sheet_manga_model_switcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_copy.xml ================================================ ================================================ FILE: app/src/main/res/resources.properties ================================================ unqualifiedResLocale=en-US ================================================ FILE: app/src/main/res/values/about_libraries.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #3C6090 #FFFFFF #D4E3FF #001C3A #545F71 #FFFFFF #D8E3F8 #111C2B #6D5676 #FFFFFF #F7D8FF #271430 #BA1A1A #FFFFFF #FFDAD6 #410002 #F9F9FF #191C20 #F9F9FF #191C20 #E0E2EC #43474E #74777F #C3C6CF #000000 #2E3035 #F0F0F7 #A5C8FF #D4E3FF #001C3A #A5C8FF #224876 #D8E3F8 #111C2B #BCC7DC #3D4758 #F7D8FF #271430 #DABDE2 #553F5D #D9DAE0 #F9F9FF #FFFFFF #F2F3FA #EDEDF4 #E7E8EE #E1E2E9 #1D4472 #FFFFFF #5376A7 #FFFFFF #394354 #FFFFFF #6B7588 #FFFFFF #513B59 #FFFFFF #856C8D #FFFFFF #8C0009 #FFFFFF #DA342E #FFFFFF #F9F9FF #191C20 #F9F9FF #191C20 #E0E2EC #3F434A #5B5F67 #777B83 #000000 #2E3035 #F0F0F7 #A5C8FF #5376A7 #FFFFFF #395D8D #FFFFFF #6B7588 #FFFFFF #525C6E #FFFFFF #856C8D #FFFFFF #6B5474 #FFFFFF #D9DAE0 #F9F9FF #FFFFFF #F2F3FA #EDEDF4 #E7E8EE #E1E2E9 #002246 #FFFFFF #1D4472 #FFFFFF #182332 #FFFFFF #394354 #FFFFFF #2E1A37 #FFFFFF #513B59 #FFFFFF #4E0002 #FFFFFF #8C0009 #FFFFFF #F9F9FF #191C20 #F9F9FF #000000 #E0E2EC #20242B #3F434A #3F434A #000000 #2E3035 #FFFFFF #E4ECFF #1D4472 #FFFFFF #002D58 #FFFFFF #394354 #FFFFFF #232D3D #FFFFFF #513B59 #FFFFFF #392542 #FFFFFF #D9DAE0 #F9F9FF #FFFFFF #F2F3FA #EDEDF4 #E7E8EE #E1E2E9 ================================================ FILE: app/src/main/res/values/day_night_01.xml ================================================ ================================================ FILE: app/src/main/res/values/expand_view_declare.xml ================================================ ================================================ FILE: app/src/main/res/values/head_line_view.xml ================================================ ================================================ FILE: app/src/main/res/values/ic_copy_background.xml ================================================ #2A85C6 ================================================ FILE: app/src/main/res/values/ic_launcher_copy_background.xml ================================================ ================================================ FILE: app/src/main/res/values/inch.xml ================================================ ================================================ FILE: app/src/main/res/values/string_array.xml ================================================ @string/japanese_r_to_l @string/korea_chinese_top_to_bottom @string/manga_mode_l_t_r copymanga.org copymanga.info copymanga.net copymanga.site copymanga.org copymanga.info copymanga.net copymanga.site ================================================ FILE: app/src/main/res/values/strings.xml ================================================ ShariganManga Recommend Day Comic Rank Month Week Total Hot New Finish TODO %s Chapters Theme Order Something Error Retry Next chapter About Search… History No input text Touch to retry Explore Load failure All clear Above chapter slider bar that show pager number History is empty Reader mode: right to left Reader model: webtoon Main Prefer orientation About project Project Look at this app github website Look app info and LICENSE Use foreign api Use the foreign api for request Tip: whatever api do you use, only the image CDN will change Setting Last update in %s Download Manga Download prepare System Clear cache %s of internal storage is used by cache Clear pager cache Download error Downloading Waiting All done! Cancelled %s Items In process of post-before-done Prepare to finish Download See the downloading content Reader mode: left to right Select the Reader mode Local Loading chapter %d / %d Personal Quick touch Tap the bottom of srceen to scroll page New version was found Update Website Disable detect new version Maybe miss some important fixes Home Select api header Region Rank Trend up No select No alias Collapse Expand No description Detail Navigate up List Preparing Downloads is empty Download the first five chapters Download all chapters Download the last five chapters Exit selection mode Select a URL host, and you need to refresh the data after switching to see the effect. Under normal circumstances, if you are not sure that it is the official reason but it cannot be loaded, you can try to switch. Toggle reading mode. Under normal circumstances, if you have set the reading mode while watching the manga, then this option will not affect the set manga. Close search dock Type search Subscribe for updates Unsubscribe for updates enable Not enabled Do you need to enable scheduled updates? This will refresh the manga at a specific time, you can manually turn it off in the settings. If not enabled you can refresh manually. Regularly update comics? After it is turned on, it will try to detect whether the manga is updated at a fixed time Look all Subscribe A third-party app for Copy Manga with Material You features Open source Update manga Completed Input Detection interval (hours) Enter the detection interval. In order to avoid the server from detecting third-party requests due to excessive throughput, it is recommended that the interval be as long as possible. In wifi In charging In low power Update constants Set the conditions when updating Work Information View information about the worker used by the application Successes Running Enqueue No select constants • Read in %d Manual refresh Continue read Start read Search is empty Read Chapter %d, Page %d Theme Mode Light Dark System Select the system theme mode, and "System" will become useless on some systems that do not have a "night mode" themselves. Display content in the notch area No next chapter No previous chapter It is recommended to turn it on. If you turn it off, the notch area will be displayed in black Note: If your phone is more aggressive about backend management, it may not update. Delete Topic Topic details Cache size Set the maximum cache size generated gor reading (MB) • Finished Mark to read Mark to no read No content Login Username Password No login User Clouded lib Local history Web history Re-register Gender nickname Fatal error Please try to send to the author Send End Add Personal information Choose author Total %s authors Author Mangas Use web reading point Start watching from your web browsing history Comment Login status is expired Login failure I don\'t know if this feature is stable. If this option affects viewing, it is recommended to turn it off. Note: If you are not logged in or your login has expired, local records will be used. Add to bookshelf Remove from bookshelf Enter content… Send comment Comic Comment Search Result 发送 Pause Resume Download comics only under Wi-Fi After turning on, downloading comics will only be downloaded under WI-FI Downloaded failure Prerequisites miss Source of inspiration This open source software will not be responsible for any content in the software Manga chapters, manga pictures, and other mangas data are provided by CopyManga Official Download list 免责声明 ================================================ FILE: app/src/main/res/values/style.xml ================================================ ================================================ FILE: app/src/main/res/values/theme_overlays.xml ================================================ ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-night/colors.xml ================================================ #A5C8FF #00315E #224876 #D4E3FF #BCC7DC #263141 #3D4758 #D8E3F8 #DABDE2 #3D2946 #553F5D #F7D8FF #FFB4AB #690005 #93000A #FFDAD6 #111318 #E1E2E9 #111318 #E1E2E9 #43474E #C3C6CF #8D9199 #43474E #000000 #E1E2E9 #2E3035 #3C6090 #D4E3FF #001C3A #A5C8FF #224876 #D8E3F8 #111C2B #BCC7DC #3D4758 #F7D8FF #271430 #DABDE2 #553F5D #111318 #37393E #0C0E13 #191C20 #1D2024 #282A2F #32353A #ACCCFF #001631 #7092C5 #000000 #C1CBE0 #0C1726 #8791A4 #000000 #DEC1E7 #210E2A #A288AB #000000 #FFBAB1 #370001 #FF5449 #000000 #111318 #E1E2E9 #111318 #FBFAFF #43474E #C8CAD4 #A0A3AB #80838B #000000 #E1E2E9 #282A2F #234977 #D4E3FF #001128 #A5C8FF #0A3764 #D8E3F8 #071120 #BCC7DC #2C3747 #F7D8FF #1C0925 #DABDE2 #432E4C #111318 #37393E #0C0E13 #191C20 #1D2024 #282A2F #32353A #FBFAFF #000000 #ACCCFF #000000 #FBFAFF #000000 #C1CBE0 #000000 #FFF9FB #000000 #DEC1E7 #000000 #FFF9F9 #000000 #FFBAB1 #000000 #111318 #E1E2E9 #111318 #FFFFFF #43474E #FBFAFF #C8CAD4 #C8CAD4 #000000 #E1E2E9 #000000 #002A53 #DBE7FF #000000 #ACCCFF #001631 #DDE7FD #000000 #C1CBE0 #0C1726 #F9DEFF #000000 #DEC1E7 #210E2A #111318 #37393E #0C0E13 #191C20 #1D2024 #282A2F #32353A ================================================ FILE: app/src/main/res/values-night/theme_overlays.xml ================================================ ================================================ FILE: app/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-zh-rCN/strings.xml ================================================ 写轮眼漫画 推荐 近24小时 排行榜 近30天 近7天 总榜 热门 最新 完结 共 %s 章 类别 排序 有些地方发生了错误 重试 下一章 关于 输入搜索内容… 观看历史 没有输入文字 点击重试 所有漫画 加载失败 全部加载完成 上一章 显示当前页数的滑动条 没有观看历史 从右向左 条漫 通用 排向偏好 关于此项目 项目地址 查看该项目的地址 查看该应用信息和许可证 使用海外线路 使用海外线路进行请求 注意:无论使用任何线路,只有图片CDN会发生变化 设置 最后更新:%s 下载漫画 准备中 系统 清除缓存 缓存已占用 %s 的内部存储空间 清除阅读器所产生的缓存 下载出错 下载中 等待中 下载完成 任务取消 %s 个项目 完成前处理中 准备完成中 下载 查看当前正在下载的内容 从左向右 选择阅读模式 本地加载 加载章节中 个人 快速点击 点击屏幕上下部分来翻页 有更新 更新 查看网页 暂停更新 有可能会错过某些重要的修复 主页 选择API 地区 排行榜 热度上升 没有选择 无别名 收起 展开 没有内容 漫画详情 " 转到上一层级" 章节列表 准备中 下载为空 下载前五章 下载所有 下载后五章 退出选择模式 选择一个网址主机,切换后要刷新数据才可以看到效果。一般情况下如果不能确定是官方的原因但是加载不出来可以尝试切换。 切换阅读模式。一般情况下,如果你在看漫画的时候设置过了阅读模式,那么这个选项将不会影响过设置过的漫画。 关闭搜索栏 键入搜索内容 订阅漫画新章节 取消订阅更新 启用 不启用 您需要启用定时更新吗?这会在特定的时间刷新漫画,您可以手动在设置里面关闭。如果不启用您可以手动刷新。 定时更新? 将会尝试在固定时间检测漫画是否更新 查看所有 订阅 拷贝漫画第三方应用,拥有Material You 特性 开源相关 更新漫画 完成 输入 检测间隔(小时) 输入检测间隔时间,为了避免吞吐量过大导致服务器检测到第三方请求,建议时长越长越好。 在WiFi下 在充电时 在低电量时 更新条件 设置更新时的条件 Work 信息 查看该应用所使用的work的信息 已成功 运行中 等待中 无限制 • 读到第 %d 页 手动刷新 继续阅读 开始阅读 搜索结果为空 读到第 %d 章 第 %d 页 主题模式 日间主题 夜间主题 跟随系统 选择系统主题模式,在某些本身就没有“夜间模式”的系统上“跟随系统”会变得没有意义。 刘海区域是否显示内容 建议打开,如果关闭刘海区域将会显示为黑色 无下一章节 无上一章节 注意:如果您的手机对后台管理比较激进,可能不会更新。 删除 专题 专题详情 缓存大小 设置对于阅读所产生的缓存大小上限(Mb) • 已看完 标记已读 标记为未读 无内容 登录 用户名 密码 没有登录 登录用户 云端书库 本地历史 网页历史 重新登录 性别 昵称 致命错误 请尝试发送给作者 发送 结束 添加 个人信息 选择作者 一共 %s 位作者 該作者的所有漫畫 使用网页端阅读点 从网页浏览的历史记录开始观看 评论 登录过期 登录失败 须知:该功能不知道是否稳定,如果此选项影响观看建议关闭。并且该功能不会精确到页数。 注意:如果没有登录或是登录过期将会使用本地记录。 添加到书架 从书架中移除 输入内容… 发送评论 漫画评论 搜索结果 发送 暂停 继续 仅在Wi-Fi下下载漫画 开启后,下载漫画仅会在WI-FI下下载 下载失败 先决条件未满足 灵感来源 本开源软件不会对软件中的任何内容负责 漫画章节、漫画的图片、漫画的其他数据经由拷贝漫画官方提供 下载队列 免责声明 ================================================ FILE: app/src/main/res/values-zh-rHK/strings.xml ================================================ 寫輪眼漫畫 推薦 近24小時 排行榜 近30天 近7天 總榜 熱門 最新 完結 共 %s 章 類別 排序 有些地方發生了錯誤 重試 下一章 關於 輸入搜索內容… 觀看歷史 沒有輸入文字 點擊重試 所有漫畫 加載失敗 全部加載完成 上一章 顯示當前頁數的滑動條 沒有觀看歷史 從右向左 條漫 通用 排向偏好 關於此項目 項目地址 查看該項目的地址 查看該應用資訊和許可證 使用海外線路 使用海外線路進行請求 注意:無論使用任何線路,只有圖片CDN會發生變化 設置 最後更新:%s 下載漫畫 準備中 系統 清除緩存 緩存已佔用 %s 的內部存儲空間 清除閱讀器所產生的緩存 下載出錯 下載中 等待中 下載完成 任務取消 %s 個項目 完成前處理中 準備完成中 下載 查看當前正在下載的內容 從左向右 選擇閱讀模式 本地加載 加載章節中 個人 快速點擊 點擊螢幕上下部分來翻頁 有更新 更新 查看網頁 暫停更新 有可能會錯過某些重要的修復 主頁 選擇API 地區 排行榜 熱度上升 沒有選擇 無別名 收起 展開 沒有內容 漫畫詳情 " 轉到上一層級" 章節列表 準備中 下載為空 下載前五章 下載所有 下載後五章 退出選擇模式 選擇一個網址主機,切換後要刷新數據才可以看到效果。一般情況下如果不能確定是官方的原因但是加載不出來可以嘗試切換。 切換閱讀模式。一般情況下,如果你在看漫畫的時候設置過了閱讀模式,那麼這個選項將不會影響過設置過的漫畫。 關閉搜索欄 鍵入搜索內容 訂閱漫畫新章節 取消訂閱更新 啟用 不啟用 您需要啟用定時更新嗎?這會在特定的時間刷新漫畫,您可以手動在設置裏面關閉。如果不啟用您可以手動刷新。 定時更新? 將會嘗試在固定時間檢測漫畫是否更新 查看所有 訂閱 拷貝漫畫第三方應用,擁有Material You 特性 開源相關 更新漫畫 完成 輸入 檢測間隔(小時) 輸入檢測間隔時間,為了避免吞吐量過大導致伺服器檢測到第三方請求,建議時長越長越好。 在WiFi下 在充電時 在低電量時 更新條件 設置更新時的條件 Work 資訊 查看該應用所使用的work的資訊 已成功 運行中 等待中 無限制 • 讀到第 %d 頁 手動刷新 繼續閱讀 開始閱讀 搜索結果為空 讀到第 %d 章 第 %d 頁 主題模式 日間主題 夜間主題 跟隨系統 選擇系統主題模式,在某些本身就沒有“夜間模式”的系統上“跟隨系統”會變得沒有意義。 瀏海區域是否顯示內容 建議打開,如果關閉瀏海區域將會顯示為黑色 無下一章節 無上一章節 注意:如果您的手機對後台管理比較激進,可能不會更新。 刪除 專題 專題詳情 緩存大小 設置對於閱讀所產生的緩存大小上限(Mb) • 已看完 標記已讀 標記為未讀 無內容 登錄 用戶名 密碼 沒有登錄 登錄用戶 雲端書庫 本地歷史 網頁歷史 重新登錄 性別 暱稱 致命錯誤 請嘗試發送給作者 發送 結束 添加 個人資訊 選擇作者 一共 %s 位作者 該作者的所有漫畫 使用網頁端閱讀點 从网页浏览的历史记录开始观看 評論 登錄過期 登錄失敗 瞭解:該功能不知道是否穩定,如果此選項影響觀看建議關閉。 注意:如果沒有登錄或是登錄過期將會使用本地記錄。 添加到書架 從書架中移除 输入内容… 發送評論 漫畫評論 搜索结果 發送 暫停 恢復 僅在Wi-Fi下載漫畫 開啟後,下載漫畫只會在WI-FI下載 下載失敗 先決條件未滿足 靈感來源 本開源軟體不會對軟體中的任何內容負責 漫畫章節、漫畫的圖片、漫畫的其他資料經由拷貝漫畫官方提供 下载队列 免责声明 ================================================ FILE: app/src/main/res/values-zh-rTW/strings.xml ================================================ 寫輪眼漫畫 推薦 近24小時 排行榜 近30天 近7天 總榜 熱門 最新 完結 共 %s 章 類別 排序 有些地方發生了錯誤 重試 下一章 關於 輸入搜索內容… 觀看歷史 沒有輸入文字 點擊重試 所有漫畫 載入失敗 全部載入完成 上一章 顯示當前頁數的滑動條 沒有觀看歷史 從右向左 條漫 通用 排向偏好 關於此項目 項目地址 查看該項目的地址 查看該應用訊息和許可證 使用海外線路 使用海外線路進行請求 注意:無論使用任何線路,只有圖片CDN會發生變化 設置 最後更新:%s 下載漫畫 準備中 系統 清除快取 快取已占用 %s 的內部儲存空間 清除閱讀器所產生的快取 下載出錯 下載中 等待中 下載完成 任務取消 %s 個項目 完成前處理中 準備完成中 下載 查看當前正在下載的內容 從左向右 選擇閱讀模式 本地載入 載入章節中 個人 快速點擊 點擊螢幕上下部分來翻頁 有更新 更新 查看網頁 暫停更新 有可能會錯過某些重要的修復 首頁 選擇API 地區 排行榜 熱度上升 沒有選擇 無別名 收起 展開 沒有內容 漫畫詳情 " 轉到上一層級" 章節列表 準備中 下載為空 下載前五章 下載所有 下載後五章 退出選擇模式 選擇一個網址主機,切換後要刷新數據才可以看到效果。一般情況下如果不能確定是官方的原因但是載入不出來可以嘗試切換。 切換閱讀模式。一般情況下,如果你在看漫畫的時候設置過了閱讀模式,那麼這個選項將不會影響過設置過的漫畫。 關閉搜索欄 鍵入搜索內容 訂閱漫畫新章節 取消訂閱更新 啟用 不啟用 您需要啟用定時更新嗎?這會在特定的時間刷新漫畫,您可以手動在設置裡面關閉。如果不啟用您可以手動刷新。 定時更新? 將會嘗試在固定時間檢測漫畫是否更新 查看所有 訂閱 拷貝漫畫第三方應用,擁有Material You 特性 開源相關 更新漫畫 完成 輸入 檢測間隔(小時) 輸入檢測間隔時間,為了避免吞吐量過大導致伺服器檢測到第三方請求,建議時長越長越好。 在WiFi下 在充電時 在低電量時 更新條件 設置更新時的條件 Work 訊息 查看該應用所使用的work的訊息 已成功 運行中 等待中 無限制 • 讀到 %d 頁 手動刷新 繼續閲讀 開始閲讀 搜索結果爲空 讀到 第 %d 章 第 %d 頁 主題模式 日間主題 夜間模式 系統跟隨 選擇系統主題模式,在某些本身就沒有“夜間模式”的系統上“跟隨系統”會變得沒有意義。 劉海區域是否展示內容 建議開啓,如果關掉劉海區域將會顯示為黑色 無下一章節 無上一章節 注意:如果您的手機對後台管理比較激進,可能不會更新。 刪除 專題 專題詳情 缓存大小 設置對於閱讀所產生的快取大小上限(兆字節) • 已看完 標記爲已讀 標記爲未讀 沒有内容 登錄 用戶名 密碼 沒有登錄 雲端書架 登錄用戶 本地記錄 遠端記錄 重新登入 性別 暱稱 致命錯誤 請嘗試發送給作者 發送 結束 添加 個人資訊 選擇作者 一共 %s 位作者 使用網頁端閱讀點 對網頁端瀏覽過的漫畫有效,啟用後如該漫畫在網頁看過,繼續觀看將不會使用本地記錄。 討論串 登錄過期 登錄失敗 明白:該功能不知道是否穩定,如果此選項影響觀看建議關閉。 注意:如果沒有登錄或是登錄過期將會使用本地記錄。 添加到書架 從書架中移除 输入内容… 發送評論 漫畫評論 搜索结果 發送 暫停 恢復 僅在Wi-Fi下載漫畫 開啟後,下載漫畫只會在WI-FI下載 下載失敗 先決條件未滿足 靈感來源 本開源軟體不會對軟體中的任何內容負責 漫畫章節、漫畫的圖片、漫畫的其他資料經由拷貝漫畫官方提供 下载队列 免责声明 ================================================ FILE: app/src/test/java/com/shicheeng/copymanga/ExampleUnitTest.java ================================================ package com.shicheeng.copymanga; import org.junit.Test; import static org.junit.Assert.*; /** * Example local unit test, which will execute on the development machine (host). * * @see Testing documentation */ public class ExampleUnitTest { @Test public void addition_isCorrect() { assertEquals(4, 2 + 2); } } ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.2.2' apply false id 'com.android.library' version '8.2.2' apply false id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'org.jetbrains.kotlin.plugin.parcelize' version '1.6.21' apply false id "androidx.navigation.safeargs.kotlin" version '2.5.3' apply false id 'com.mikepenz.aboutlibraries.plugin' version "10.5.2" apply false id 'com.google.dagger.hilt.android' version '2.48' apply false id 'com.google.devtools.ksp' version '1.9.22-1.0.16' apply false } tasks.register('clean', Delete) { delete rootProject.buildDir } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Wed Mar 09 21:22:47 CST 2022 distributionBase=GRADLE_USER_HOME distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: local.properties ================================================ ## This file is automatically generated by Android Studio. # Do not modify this file -- YOUR CHANGES WILL BE ERASED! # # This file should *NOT* be checked into Version Control Systems, # as it contains information specific to your local configuration. # # Location of the SDK. This is only used by Gradle. # For customization when using a Version Control System, please read the # header note. sdk.dir=C\:\\Users\\ShihCheeng\\AppData\\Local\\Android\\Sdk ================================================ FILE: resources.properties ================================================ unqualifiedResLocale=zh-CN ================================================ FILE: settings.gradle ================================================ pluginManagement { repositories { gradlePluginPortal() google() mavenCentral() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url 'https://jitpack.io' } } } rootProject.name = "CopyMangaJava" include ':app'