From bb87a3d1381062710481deaa9d3cfe73a1c95a77 Mon Sep 17 00:00:00 2001 From: renhaoting <370797079@qq.com> Date: Tue, 21 Oct 2025 13:32:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E5=88=9D=E6=AD=A5=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VisualNovel/.gitignore | 17 + VisualNovel/.idea/.gitignore | 3 + VisualNovel/.idea/codeStyles/Project.xml | 123 ++ .../.idea/codeStyles/codeStyleConfig.xml | 5 + VisualNovel/.idea/compiler.xml | 6 + .../.idea/deploymentTargetSelector.xml | 10 + VisualNovel/.idea/deviceManager.xml | 13 + VisualNovel/.idea/gradle.xml | 35 + VisualNovel/.idea/kotlinc.xml | 6 + VisualNovel/.idea/migrations.xml | 10 + VisualNovel/.idea/misc.xml | 9 + VisualNovel/.idea/runConfigurations.xml | 17 + VisualNovel/.idea/vcs.xml | 6 + VisualNovel/app/.gitignore | 1 + VisualNovel/app/build.gradle.kts | 283 +++ VisualNovel/app/libs/ninepatch-1.0.aar | Bin 0 -> 15004 bytes VisualNovel/app/proguard-rules.pro | 21 + .../visualnovel/ExampleInstrumentedTest.kt | 24 + .../src/main/assets/family/Bangers-400.ttf | Bin 0 -> 38024 bytes .../app/src/main/assets/family/D-Din-700.ttf | Bin 0 -> 60188 bytes .../src/main/assets/family/Poppins-400.ttf | Bin 0 -> 158240 bytes .../src/main/assets/family/Poppins-500.ttf | Bin 0 -> 156520 bytes .../src/main/assets/family/Poppins-600.ttf | Bin 0 -> 155232 bytes .../src/main/assets/family/Poppins-700.ttf | Bin 0 -> 153944 bytes .../src/main/assets/uitoken/token_sys.json | 202 +++ .../visualnovel/api/factory/ServiceFactory.kt | 72 + .../api/interceptor/GlobalInterceptor.kt | 89 + .../api/interceptor/util/Base64.java | 102 ++ .../visualnovel/api/interceptor/util/Md5.java | 37 + .../visualnovel/api/service/BookService.kt | 13 + .../visualnovel/api/service/DictService.kt | 33 + .../visualnovel/api/service/LoginService.kt | 30 + .../visualnovel/api/service/MessageService.kt | 34 + .../visualnovel/api/service/UserService.kt | 52 + .../app/ProcessLifecycleObserver.kt | 45 + .../XActivityResultContract.kt | 37 + .../app/base/BaseBindingActivity.kt | 144 ++ .../app/base/BaseBindingFragment.kt | 112 ++ .../app/base/BaseCommonNavigatorAdapter.kt | 13 + .../app/base/app/AppViewModelFactory.kt | 30 + .../app/base/app/ApplicationProxy.kt | 11 + .../app/base/app/CommonApplicationProxy.kt | 22 + .../app/delegate/ToolbarViewDelegate.kt | 264 +++ .../visualnovel/app/di/ApiServiceModule.kt | 47 + .../visualnovel/app/di/ViewModelModule.kt | 31 + .../initializer/AppInitializerStartType.kt | 18 + .../app/initializer/AppInitializers.kt | 25 + .../initializer/AppInitializersProvider.kt | 41 + .../di/AppInitializerEntryPoint.kt | 18 + .../initializer/di/AppInitializerPriority.kt | 19 + .../initializer/di/AppInitializersModule.kt | 42 + .../initializer/impl/LocalDataInitializer.kt | 27 + .../app/initializer/impl/RouterInitializer.kt | 26 + .../app/initializer/impl/SystemInitializer.kt | 47 + .../app/initializer/impl/ThirdInitializer.kt | 153 ++ .../app/initializer/impl/UserInitializer.kt | 20 + .../app/initializer/utils/FirebaseHelper.kt | 29 + .../app/viewmodel/AppGlobalViewModel.kt | 37 + .../app/viewmodel/AppIMViewModel.kt | 32 + .../app/viewmodel/base/AppViewModel.kt | 16 + .../app/viewmodel/base/BaseViewModel.kt | 16 + .../app/viewmodel/base/PayViewModel.kt | 28 + .../app/viewmodel/base/UserViewModel.kt | 72 + .../app/widget/CustomLoadMoreView.kt | 40 + .../remax/visualnovel/app/widget/EmptyView.kt | 113 ++ .../app/widget/LoadMoreFooter.java | 92 + .../visualnovel/app/widget/LoadingDialog.kt | 40 + .../visualnovel/app/widget/RefreshHeader.java | 109 ++ .../app/widget/tips/TipsFeedbackWindow.kt | 147 ++ .../app/widget/tips/TipsMoreWindow.kt | 92 + .../app/widget/tips/TipsPopWindow.java | 76 + .../app/widget/tips/TipsSwitchWindow.kt | 60 + .../visualnovel/configs/NovelApplication.kt | 60 + .../remax/visualnovel/constant/AppConstant.kt | 13 + .../remax/visualnovel/constant/AppStatus.kt | 18 + .../com/remax/visualnovel/constant/Gender.kt | 15 + .../visualnovel/constant/LockTypeConstant.kt | 24 + .../remax/visualnovel/constant/StatusCode.kt | 27 + .../entity/imbean/raw/CustomAlbumData.kt | 24 + .../entity/imbean/raw/CustomCallData.kt | 41 + .../entity/imbean/raw/CustomGiftData.kt | 12 + .../imbean/raw/CustomLevelChangeData.kt | 17 + .../entity/imbean/raw/CustomRawData.kt | 36 + .../entity/imbean/raw/CustomScoreData.kt | 8 + .../visualnovel/entity/model/MyImgData.kt | 19 + .../entity/model/base/BasePhoto.kt | 21 + .../request/PlatformAccountVerifyDTO.kt | 30 + .../entity/response/AppearanceImage.kt | 32 + .../remax/visualnovel/entity/response/Book.kt | 11 + .../visualnovel/entity/response/Character.kt | 96 + .../entity/response/ChatBackground.kt | 18 + .../entity/response/PlatformAccountVerify.kt | 22 + .../remax/visualnovel/entity/response/User.kt | 27 + .../entity/response/base/BaseVoice.kt | 19 + .../entity/response/base/Response.kt | 92 + .../visualnovel/event/model/OnLoginEvent.kt | 14 + .../event/model/OnPlayVoiceEvent.kt | 9 + .../visualnovel/event/model/tab/MainTab.kt | 25 + .../event/model/tab/OnTabChangedEvent.kt | 9 + .../visualnovel/event/modular/UIEvents.kt | 31 + .../visualnovel/event/modular/UserEvents.kt | 37 + .../visualnovel/extension/AppBarLayoutExt.kt | 106 ++ .../extension/AppCompatActivityExt.kt | 52 + .../visualnovel/extension/BlurViewExt.kt | 14 + .../remax/visualnovel/extension/ButtonExt.kt | 41 + .../remax/visualnovel/extension/ContextExt.kt | 244 +++ .../visualnovel/extension/CoroutineExt.kt | 14 + .../remax/visualnovel/extension/DialogExt.kt | 291 +++ .../remax/visualnovel/extension/DoubleExt.kt | 11 + .../visualnovel/extension/EditTextExt.kt | 67 + .../remax/visualnovel/extension/FileExt.kt | 41 + .../remax/visualnovel/extension/FloatExt.kt | 15 + .../remax/visualnovel/extension/FlowKtx.kt | 144 ++ .../remax/visualnovel/extension/GlobalExt.kt | 145 ++ .../visualnovel/extension/ImageViewExt.kt | 30 + .../com/remax/visualnovel/extension/IntExt.kt | 11 + .../remax/visualnovel/extension/ListExt.kt | 88 + .../remax/visualnovel/extension/LongExt.kt | 66 + .../extension/MagicIndicatorExt.kt | 148 ++ .../remax/visualnovel/extension/Postcard.kt | 26 + .../visualnovel/extension/RecyclerViewExt.kt | 52 + .../visualnovel/extension/ResourcesExt.kt | 24 + .../visualnovel/extension/ShowAndHideExt.kt | 141 ++ .../remax/visualnovel/extension/StringExt.kt | 100 ++ .../visualnovel/extension/SwitchViewExt.kt | 34 + .../visualnovel/extension/TextViewExt.kt | 106 ++ .../remax/visualnovel/extension/ViewExt.kt | 116 ++ .../remax/visualnovel/extension/WindowExt.kt | 26 + .../visualnovel/extension/glide/GlideExt.kt | 259 +++ .../extension/ui/IconButtonViewExt.kt | 23 + .../visualnovel/manager/CustomToastManager.kt | 67 + .../manager/login/LoginInfoSave.kt | 35 + .../visualnovel/manager/login/LoginManager.kt | 86 + .../repository/api/BooksRepository.kt | 14 + .../repository/api/DictRepository.kt | 25 + .../repository/api/HistoryRepository.kt | 14 + .../repository/api/LoginRepository.kt | 50 + .../repository/api/ManagasRepository.kt | 14 + .../repository/api/MessageRepository.kt | 15 + .../repository/api/UserRepository.kt | 58 + .../repository/api/base/BaseRepository.kt | 94 + .../repository/mmkv/SystemRepository.kt | 21 + .../repository/mmkv/UserLocalRepository.kt | 18 + .../remax/visualnovel/ui/main/MainActivity.kt | 221 +++ .../visualnovel/ui/main/MainViewModel.kt | 42 + .../ui/main/book/BookListFragment.kt | 43 + .../ui/main/book/BookListViewModel.kt | 25 + .../ui/main/history/HistoryFragment.kt | 43 + .../ui/main/history/HistoryViewModel.kt | 26 + .../ui/main/managa/MangaListFragment.kt | 43 + .../ui/main/managa/MangaListViewModel.kt | 25 + .../com/remax/visualnovel/utils/AppUtils.kt | 23 + .../com/remax/visualnovel/utils/EpalUtils.kt | 137 ++ .../visualnovel/utils/KeyboardUtils.java | 385 ++++ .../remax/visualnovel/utils/NotLoggingTree.kt | 14 + .../com/remax/visualnovel/utils/Routers.kt | 54 + .../remax/visualnovel/utils/SpanUtils.java | 1476 +++++++++++++++ .../remax/visualnovel/utils/StatusBarUtils.kt | 194 ++ .../com/remax/visualnovel/utils/TimeUtils.kt | 227 +++ .../utils/datastore/DataStoreOwner.kt | 73 + .../utils/datastore/DataStorePreference.kt | 58 + .../remax/visualnovel/utils/mmkv/MMKVExt.kt | 100 ++ .../utils/spannablex/ReplaceRule.kt | 74 + .../visualnovel/utils/spannablex/Span.kt | 932 ++++++++++ .../visualnovel/utils/spannablex/SpanDsl.kt | 812 +++++++++ .../utils/spannablex/SpanInternal.kt | 826 +++++++++ .../utils/spannablex/SpannableX.kt | 151 ++ .../spannablex/annotation/ConversionUnit.kt | 45 + .../utils/spannablex/annotation/TextStyle.kt | 27 + .../interfaces/OnSpanClickListener.java | 36 + .../interfaces/OnSpanReplacementMatch.java | 34 + .../spannablex/span/LeadingMarginSpan.kt | 42 + .../spannablex/span/ParagraphBitmapSpan.kt | 83 + .../spannablex/span/ParagraphDrawableSpan.kt | 74 + .../spannablex/span/SimpleClickableSpan.kt | 72 + .../span/legacy/LegacyBulletSpan.kt | 56 + .../span/legacy/LegacyLineBackgroundSpan.kt | 40 + .../span/legacy/LegacyLineHeightSpan.kt | 41 + .../spannablex/span/legacy/LegacyQuoteSpan.kt | 60 + .../utils/spannablex/utils/DrawableSize.kt | 57 + .../utils/spannablex/utils/SpanUtils.kt | 59 + .../com/remax/visualnovel/widget/PriceView.kt | 63 + .../widget/custom/TagFlowLayout2.kt | 333 ++++ .../visualnovel/widget/custom/TagItem.kt | 7 + .../widget/dialoglib/LBindingDialog.kt | 444 +++++ .../widget/dialoglib/ScreenUtils.java | 77 + .../CropRectTransformation.java | 128 ++ .../widget/imagepicker/ImagePicker.java | 365 ++++ .../activity/PBaseLoaderFragment.java | 521 ++++++ .../activity/PickerActivityManager.java | 66 + .../activity/crop/MultiImageCropActivity.java | 122 ++ .../activity/crop/MultiImageCropFragment.java | 827 +++++++++ .../multi/MultiImagePickerActivity.java | 129 ++ .../multi/MultiImagePickerFragment.java | 552 ++++++ .../preview/MultiImagePreviewActivity.java | 382 ++++ .../singlecrop/SingleCropActivity.java | 269 +++ .../adapter/MultiPreviewAdapter.java | 117 ++ .../adapter/PickerFolderAdapter.java | 105 ++ .../adapter/PickerItemAdapter.java | 240 +++ .../imagepicker/bean/ImageCropMode.java | 27 + .../widget/imagepicker/bean/ImageItem.java | 409 +++++ .../widget/imagepicker/bean/ImageSet.java | 84 + .../widget/imagepicker/bean/MimeType.java | 144 ++ .../widget/imagepicker/bean/PickerError.java | 59 + .../bean/PickerItemDisableCode.java | 139 ++ .../widget/imagepicker/bean/SelectMode.java | 13 + .../widget/imagepicker/bean/UriPathInfo.java | 62 + .../bean/selectconfig/BaseSelectConfig.java | 195 ++ .../bean/selectconfig/CropConfig.java | 166 ++ .../selectconfig/CropConfigParcelable.java | 212 +++ .../bean/selectconfig/CropSelectConfig.java | 39 + .../bean/selectconfig/MultiSelectConfig.java | 91 + .../builder/CropPickerBuilder.java | 263 +++ .../builder/MultiPickerBuilder.java | 426 +++++ .../imagepicker/data/ICameraExecutor.java | 15 + .../imagepicker/data/IReloadExecutor.java | 16 + .../data/MediaItemsDataSource.java | 301 ++++ .../imagepicker/data/MediaItemsLoader.java | 84 + .../imagepicker/data/MediaSetsDataSource.java | 149 ++ .../imagepicker/data/MediaSetsLoader.java | 174 ++ .../imagepicker/data/MediaStoreConstants.java | 40 + .../data/OnImagePickCompleteListener.java | 18 + .../data/OnImagePickCompleteListener2.java | 14 + .../data/OnPickerCompleteListener.java | 41 + .../data/OnStringCompleteListener.java | 36 + .../data/OnStringListCompleteListener.java | 37 + .../data/PickerActivityCallBack.java | 42 + .../imagepicker/data/ProgressSceneEnum.java | 6 + .../imagepicker/helper/CameraCompat.java | 144 ++ .../helper/CropViewContainerHelper.java | 169 ++ .../helper/DetailImageLoadHelper.java | 24 + .../helper/PickerErrorExecutor.java | 29 + .../helper/RecyclerViewTouchHelper.java | 339 ++++ .../helper/VideoViewContainerHelper.java | 122 ++ .../helper/launcher/PLauncher.java | 106 ++ .../imagepicker/helper/launcher/PRouter.java | 72 + .../helper/launcher/PRouterV4.java | 66 + .../ItemTouchHelperAdapter.java | 60 + .../ItemTouchHelperViewHolder.java | 41 + .../SimpleItemTouchHelperCallback.java | 195 ++ .../presenter/IPickerPresenter.java | 168 ++ .../widget/imagepicker/utils/MediaUtils.java | 240 +++ .../imagepicker/utils/PBitmapUtils.java | 467 +++++ .../imagepicker/utils/PCornerUtils.java | 39 + .../widget/imagepicker/utils/PDateUtil.java | 153 ++ .../imagepicker/utils/PPermissionUtils.java | 162 ++ .../utils/PSingleMediaScanner.java | 43 + .../imagepicker/utils/PStatusBarUtil.java | 174 ++ .../imagepicker/utils/PViewSizeUtils.java | 254 +++ .../imagepicker/utils/PickerFileProvider.java | 30 + .../imagepicker/views/PickerUiConfig.java | 199 ++ .../imagepicker/views/PickerUiProvider.java | 77 + .../imagepicker/views/base/PBaseLayout.java | 68 + .../views/base/PickerControllerView.java | 86 + .../views/base/PickerFolderItemView.java | 51 + .../views/base/PickerItemView.java | 103 ++ .../views/base/PreviewControllerView.java | 149 ++ .../views/base/SingleCropControllerView.java | 46 + .../imagepicker/views/wrapper/BottomBar.kt | 80 + .../views/wrapper/FolderItemView.kt | 55 + .../imagepicker/views/wrapper/ItemView.kt | 100 ++ .../views/wrapper/PreviewControllerView.kt | 185 ++ .../views/wrapper/SingleCropControllerView.kt | 50 + .../imagepicker/views/wrapper/TitleBar.kt | 94 + .../imagepicker/widget/ShowTypeImageView.java | 184 ++ .../imagepicker/widget/TouchRecyclerView.java | 106 ++ .../widget/cropimage/CropImageView.java | 1596 +++++++++++++++++ .../imagepicker/widget/cropimage/Info.java | 106 ++ .../cropimage/RotateGestureDetector.java | 70 + .../widget/imageviewer/BaseDialogFragment.kt | 90 + .../imageviewer/ImageViewerActionViewModel.kt | 21 + .../imageviewer/ImageViewerAdapterListener.kt | 11 + .../widget/imageviewer/ImageViewerBuilder.kt | 61 + .../imageviewer/ImageViewerDialogFragment.kt | 190 ++ .../imageviewer/ImageViewerViewModel.kt | 26 + .../widget/imageviewer/ViewerActions.kt | 7 + .../imageviewer/adapter/ImageViewerAdapter.kt | 93 + .../widget/imageviewer/adapter/ItemType.kt | 15 + .../widget/imageviewer/adapter/Repository.kt | 83 + .../widget/imageviewer/core/Components.kt | 56 + .../widget/imageviewer/core/DataProvider.kt | 8 + .../widget/imageviewer/core/ImageLoader.kt | 12 + .../imageviewer/core/OverlayCustomizer.kt | 8 + .../widget/imageviewer/core/Photo.kt | 9 + .../imageviewer/core/SimpleDataProvider.kt | 40 + .../widget/imageviewer/core/Transformer.kt | 8 + .../widget/imageviewer/core/VHCustomizer.kt | 8 + .../widget/imageviewer/core/ViewerCallback.kt | 15 + .../widget/imageviewer/utils/Config.kt | 20 + .../widget/imageviewer/utils/Extensions.kt | 30 + .../imageviewer/utils/TransitionEndHelper.kt | 197 ++ .../utils/TransitionStartHelper.kt | 178 ++ .../imageviewer/utils/ViewModelUtils.kt | 17 + .../imageviewer/viewer/MyImageLoader.kt | 225 +++ .../imageviewer/viewer/MyTransformer.kt | 43 + .../imageviewer/viewer/MyViewerCustomizer.kt | 555 ++++++ .../widget/imageviewer/viewer/ViewerHelper.kt | 61 + .../viewholders/PhotoViewHolder.kt | 37 + .../viewholders/SubsamplingViewHolder.kt | 41 + .../viewholders/UnknownViewHolder.kt | 6 + .../viewholders/VideoViewHolder.kt | 37 + .../imageviewer/widgets/BackgroundView.kt | 46 + .../imageviewer/widgets/InterceptLayout.kt | 13 + .../widget/imageviewer/widgets/PhotoView2.kt | 123 ++ .../widgets/SubsamplingScaleImageView2.kt | 135 ++ .../imageviewer/widgets/video/ExoVideoView.kt | 182 ++ .../widgets/video/ExoVideoView2.kt | 147 ++ .../widget/indicator/ViewPager2Helper.kt | 27 + .../itemdecoration/GridSpaceItemDecoration.kt | 42 + .../HorizontalGridSpaceItemDecoration.kt | 42 + .../HorizontalItemDecoration.kt | 56 + .../itemdecoration/SpaceItemDecoration.kt | 36 + .../itemdecoration/VerticalItemDecoration.kt | 38 + .../widget/roundedimageview/Corner.java | 18 + .../roundedimageview/RoundedDrawable.java | 618 +++++++ .../roundedimageview/RoundedImageView.java | 587 ++++++ .../visualnovel/widget/ui/CheckBoxButton.kt | 64 + .../visualnovel/widget/ui/IconFontDrawable.kt | 191 ++ .../visualnovel/widget/ui/IconFontTextView.kt | 114 ++ .../remax/visualnovel/widget/ui/LikeView.kt | 105 ++ .../visualnovel/widget/ui/RadioCheckButton.kt | 47 + .../visualnovel/widget/ui/RadioOnPicButton.kt | 61 + .../remax/visualnovel/widget/ui/SwitchView.kt | 182 ++ .../visualnovel/widget/ui/UserAvatarView.kt | 172 ++ .../ui/bannerindicator/NumberIndicator.kt | 69 + .../ui/bannerindicator/RectangleIndicator.kt | 82 + .../widget/ui/buttons/ButtonView.kt | 289 +++ .../ui/buttons/FloatActionButtonView.kt | 46 + .../widget/ui/buttons/IconButtonView.kt | 275 +++ .../widget/ui/buttons/TextButtonView.kt | 91 + .../widget/ui/lock/ButtonIconExt.kt | 50 + .../visualnovel/widget/ui/lock/LockTagView.kt | 95 + .../widget/ui/lock/LockViewGroup.kt | 112 ++ .../widget/ui/tags/ColorSupportTagView.kt | 105 ++ .../widget/ui/tags/ContextTagView.kt | 88 + .../widget/ui/tags/OfficialTagView.kt | 73 + .../widget/ui/tags/PrimaryTagView.kt | 86 + .../widget/ui/tags/constant/TagSize.kt | 11 + .../widget/uitoken/CustomViewTokenExt.kt | 493 +++++ .../visualnovel/widget/uitoken/UITokenExt.kt | 234 +++ .../widget/uitoken/bean/CustomViewToken.kt | 44 + .../widget/uitoken/bean/UIToken.kt | 55 + .../uitoken/expend/dsl/LayoutHelperFun.kt | 135 ++ .../uitoken/view/UITokenConstraintLayout.kt | 60 + .../widget/uitoken/view/UITokenEditView.kt | 62 + .../widget/uitoken/view/UITokenFrameLayout.kt | 60 + .../widget/uitoken/view/UITokenImageView.kt | 62 + .../uitoken/view/UITokenLinearLayout.kt | 60 + .../widget/uitoken/view/UITokenProgressBar.kt | 70 + .../uitoken/view/UITokenRelativeLayout.kt | 60 + .../widget/uitoken/view/UITokenTextView.kt | 152 ++ .../visualnovel/widget/uitoken/view/UIView.kt | 12 + .../app/src/main/res/anim/anim_keep.xml | 5 + .../src/main/res/anim/dialog_alpha_cancel.xml | 8 + .../src/main/res/anim/dialog_alpha_show.xml | 8 + .../main/res/anim/dialog_translate_cancel.xml | 14 + .../main/res/anim/dialog_translate_show.xml | 13 + VisualNovel/app/src/main/res/anim/no_anim.xml | 2 + .../app/src/main/res/anim/picker_anim_in.xml | 8 + .../app/src/main/res/anim/picker_anim_up.xml | 8 + .../app/src/main/res/anim/picker_fade_in.xml | 7 + .../app/src/main/res/anim/picker_fade_out.xml | 25 + .../src/main/res/anim/picker_hide2bottom.xml | 31 + .../src/main/res/anim/picker_show2bottom.xml | 13 + .../app/src/main/res/anim/picker_top_in.xml | 7 + .../app/src/main/res/anim/picker_top_out.xml | 6 + .../main/res/drawable/fragment_actor_bg.xml | 18 + .../res/drawable/ic_launcher_background.xml | 170 ++ .../res/drawable/ic_launcher_foreground.xml | 30 + .../main/res/drawable/progress_recording.xml | 34 + .../res/drawable/shape_dialog_text_mask.xml | 7 + .../res/drawable/shape_dialog_text_mask_2.xml | 7 + .../main/res/drawable/tag_flow_item_bg.xml | 6 + .../res/layout/button_vip_with_iconfont.xml | 45 + .../src/main/res/layout/button_with_icon.xml | 47 + .../src/main/res/layout/dialog_double_btn.xml | 91 + .../src/main/res/layout/dialog_loading.xml | 18 + .../res/layout/dialog_single_btn_layout.xml | 74 + .../res/layout/dialog_single_btn_layout_2.xml | 91 + .../layout/dialog_single_btn_with_icon.xml | 75 + .../layout/fragment_image_viewer_dialog.xml | 27 + .../main/res/layout/fragment_main_actor.xml | 44 + .../main/res/layout/fragment_main_book.xml | 23 + .../main/res/layout/fragment_main_history.xml | 23 + .../main/res/layout/fragment_main_manga.xml | 23 + .../app/src/main/res/layout/item_album.xml | 59 + .../res/layout/item_imageviewer_photo.xml | 10 + .../layout/item_imageviewer_subsampling.xml | 10 + .../res/layout/item_imageviewer_video.xml | 16 + .../res/layout/item_photo_custom_layout.xml | 57 + .../app/src/main/res/layout/layout_empty.xml | 47 + .../src/main/res/layout/layout_epal_crop.xml | 36 + .../src/main/res/layout/layout_toolbar.xml | 144 ++ .../res/layout/load_more_loading_view.xml | 25 + .../src/main/res/layout/pick_bottom_bar.xml | 33 + .../main/res/layout/picker_activity_crop.xml | 25 + .../res/layout/picker_activity_crop_cover.xml | 26 + .../picker_activity_fragment_wrapper.xml | 17 + .../res/layout/picker_activity_multi_crop.xml | 116 ++ .../res/layout/picker_activity_multipick.xml | 56 + .../res/layout/picker_activity_preview.xml | 17 + .../main/res/layout/picker_folder_item.xml | 65 + .../res/layout/picker_image_grid_item.xml | 83 + .../main/res/layout/picker_item_camera.xml | 19 + .../src/main/res/layout/picker_item_root.xml | 5 + .../res/layout/picker_redbook_titlebar.xml | 11 + .../layout/picker_wx_preview_bottombar.xml | 59 + .../main/res/layout/popwindow_btn_tips.xml | 23 + .../res/layout/popwindow_feeds_back_tips.xml | 98 + .../main/res/layout/popwindow_switch_tips.xml | 41 + .../src/main/res/layout/popwindow_tips.xml | 32 + .../main/res/layout/view_load_more_common.xml | 74 + .../src/main/res/layout/widget_item_like.xml | 62 + .../src/main/res/layout/widget_lock_tag.xml | 67 + .../src/main/res/layout/widget_price_view.xml | 25 + .../main/res/layout/widget_user_avatar.xml | 49 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../res/mipmap-xxhdpi/icon_new_empty.webp | Bin 0 -> 5914 bytes .../res/mipmap-xxhdpi/main_tab_actor_off.webp | Bin 0 -> 2616 bytes .../res/mipmap-xxhdpi/main_tab_actor_on.webp | Bin 0 -> 3234 bytes .../res/mipmap-xxhdpi/main_tab_book_off.webp | Bin 0 -> 2308 bytes .../res/mipmap-xxhdpi/main_tab_book_on.webp | Bin 0 -> 2550 bytes .../mipmap-xxhdpi/main_tab_history_off.webp | Bin 0 -> 2014 bytes .../mipmap-xxhdpi/main_tab_history_on.webp | Bin 0 -> 2088 bytes .../res/mipmap-xxhdpi/main_tab_manga_off.webp | Bin 0 -> 2218 bytes .../res/mipmap-xxhdpi/main_tab_manga_on.webp | Bin 0 -> 2224 bytes .../res/mipmap-xxhdpi/tag_flow_expand.webp | Bin 0 -> 1474 bytes .../res/mipmap-xxhdpi/tag_flow_shrink.webp | Bin 0 -> 1452 bytes .../res/mipmap-xxxhdpi/checkbox_normal.webp | Bin 0 -> 306 bytes .../res/mipmap-xxxhdpi/ic_gender_female.webp | Bin 0 -> 1066 bytes .../res/mipmap-xxxhdpi/ic_gender_male.webp | Bin 0 -> 1010 bytes .../ic_gender_nonconforming.png | Bin 0 -> 2222 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../res/mipmap-xxxhdpi/ic_status_error.webp | Bin 0 -> 4580 bytes .../src/main/res/mipmap-xxxhdpi/ic_tips.webp | Bin 0 -> 736 bytes .../main/res/mipmap-xxxhdpi/icon_checked.webp | Bin 0 -> 1010 bytes .../main/res/mipmap-xxxhdpi/icon_diamond.webp | Bin 0 -> 2554 bytes .../mipmap-xxxhdpi/icon_multi_checked.webp | Bin 0 -> 492 bytes .../icon_multi_checked_disabled.webp | Bin 0 -> 1146 bytes .../res/mipmap-xxxhdpi/picker_icon_fill.png | Bin 0 -> 1187 bytes .../res/mipmap-xxxhdpi/picker_icon_fit.png | Bin 0 -> 913 bytes .../res/mipmap-xxxhdpi/picker_icon_full.png | Bin 0 -> 902 bytes .../mipmap-xxxhdpi/picker_icon_haswhite.png | Bin 0 -> 969 bytes .../res/mipmap-xxxhdpi/picker_icon_video.png | Bin 0 -> 7629 bytes .../res/mipmap-xxxhdpi/picker_item_video.png | Bin 0 -> 658 bytes .../mipmap-xxxhdpi/picker_item_video_mask.png | Bin 0 -> 23692 bytes .../main/res/mipmap-xxxhdpi/radio_normal.webp | Bin 0 -> 634 bytes VisualNovel/app/src/main/res/raw/like.json | 1 + .../app/src/main/res/raw/single_ring.json | 1 + .../app/src/main/res/values-night/themes.xml | 7 + VisualNovel/app/src/main/res/values/attrs.xml | 1452 +++++++++++++++ .../app/src/main/res/values/colors.xml | 188 ++ .../app/src/main/res/values/dimens.xml | 146 ++ .../app/src/main/res/values/iconfontkey.xml | 332 ++++ VisualNovel/app/src/main/res/values/ids.xml | 11 + .../app/src/main/res/values/strings.xml | 466 +++++ .../app/src/main/res/values/styles.xml | 233 +++ .../app/src/main/res/values/themes.xml | 9 + .../app/src/main/res/values/ui_tokens.xml | 203 +++ .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../com/remax/visualnovel/ExampleUnitTest.kt | 17 + VisualNovel/build.gradle.kts | 23 + VisualNovel/buildSrc/build.gradle.kts | 7 + .../classes/kotlin/main/Deps$ClassPath.class | Bin 0 -> 1084 bytes .../build/classes/kotlin/main/Deps.class | Bin 0 -> 9618 bytes .../main/META-INF/buildSrc.kotlin_module | Bin 0 -> 24 bytes .../build/classes/kotlin/main/Version.class | Bin 0 -> 4867 bytes .../cacheable/caches-jvm/lookups/counters.tab | 2 + .../compileKotlin/cacheable/last-build.bin | Bin 0 -> 18 bytes .../shrunk-classpath-snapshot.bin | Bin 0 -> 4 bytes VisualNovel/buildSrc/build/libs/buildSrc.jar | Bin 0 -> 7702 bytes .../buildSrc/build/tmp/jar/MANIFEST.MF | 2 + VisualNovel/buildSrc/src/main/java/Deps.kt | 278 +++ VisualNovel/gradle.properties | 43 + VisualNovel/gradle/libs.versions.toml | 26 + VisualNovel/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + VisualNovel/gradlew | 251 +++ VisualNovel/gradlew.bat | 94 + VisualNovel/loadingstateview-ktx/.gitignore | 1 + .../loadingstateview-ktx/build.gradle.kts | 59 + .../loadingstateview-ktx/consumer-rules.pro | 0 .../loadingstateview-ktx/proguard-rules.pro | 21 + .../BaseToolbarViewDelegate.kt | 32 + .../com/dylanc/loadingstateview/Decorative.kt | 24 + .../dylanc/loadingstateview/LoadingState.kt | 68 + .../loadingstateview/LoadingStateDelegate.kt | 114 ++ .../dylanc/loadingstateview/ToolbarConfig.kt | 129 ++ VisualNovel/loadingstateview/.gitignore | 1 + VisualNovel/loadingstateview/build.gradle.kts | 45 + .../loadingstateview/proguard-rules.pro | 21 + .../loadingstateview/LoadingStateView.kt | 275 +++ .../src/main/res/values/strings.xml | 3 + VisualNovel/settings.gradle.kts | 35 + VisualNovel/viewbinding-base/.gitignore | 1 + VisualNovel/viewbinding-base/build.gradle.kts | 67 + .../viewbinding-base/consumer-rules.pro | 4 + .../viewbinding-base/proguard-rules.pro | 21 + .../viewbinding/base/FragmentBinding.kt | 52 + .../viewbinding/base/ViewBindingUtil.kt | 98 + .../src/main/res/values/ids.xml | 4 + .../viewbinding-nonreflection-ktx/.gitignore | 1 + .../build.gradle.kts | 66 + .../consumer-rules.pro | 0 .../proguard-rules.pro | 21 + .../viewbinding/nonreflection/Dialog.kt | 26 + .../viewbinding/nonreflection/Fragment.kt | 66 + .../dylanc/viewbinding/nonreflection/View.kt | 29 + .../viewbinding/nonreflection/ViewGroup.kt | 32 + .../src/main/res/values/ids.xml | 4 + VisualNovel/visual_noval_keystore | Bin 0 -> 2762 bytes 522 files changed, 44342 insertions(+) create mode 100644 VisualNovel/.gitignore create mode 100644 VisualNovel/.idea/.gitignore create mode 100644 VisualNovel/.idea/codeStyles/Project.xml create mode 100644 VisualNovel/.idea/codeStyles/codeStyleConfig.xml create mode 100644 VisualNovel/.idea/compiler.xml create mode 100644 VisualNovel/.idea/deploymentTargetSelector.xml create mode 100644 VisualNovel/.idea/deviceManager.xml create mode 100644 VisualNovel/.idea/gradle.xml create mode 100644 VisualNovel/.idea/kotlinc.xml create mode 100644 VisualNovel/.idea/migrations.xml create mode 100644 VisualNovel/.idea/misc.xml create mode 100644 VisualNovel/.idea/runConfigurations.xml create mode 100644 VisualNovel/.idea/vcs.xml create mode 100644 VisualNovel/app/.gitignore create mode 100644 VisualNovel/app/build.gradle.kts create mode 100644 VisualNovel/app/libs/ninepatch-1.0.aar create mode 100644 VisualNovel/app/proguard-rules.pro create mode 100644 VisualNovel/app/src/androidTest/java/com/remax/visualnovel/ExampleInstrumentedTest.kt create mode 100644 VisualNovel/app/src/main/assets/family/Bangers-400.ttf create mode 100644 VisualNovel/app/src/main/assets/family/D-Din-700.ttf create mode 100644 VisualNovel/app/src/main/assets/family/Poppins-400.ttf create mode 100644 VisualNovel/app/src/main/assets/family/Poppins-500.ttf create mode 100644 VisualNovel/app/src/main/assets/family/Poppins-600.ttf create mode 100644 VisualNovel/app/src/main/assets/family/Poppins-700.ttf create mode 100644 VisualNovel/app/src/main/assets/uitoken/token_sys.json create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/factory/ServiceFactory.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt create mode 100644 VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt create mode 100644 VisualNovel/app/src/main/res/anim/anim_keep.xml create mode 100644 VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml create mode 100644 VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml create mode 100644 VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml create mode 100644 VisualNovel/app/src/main/res/anim/dialog_translate_show.xml create mode 100644 VisualNovel/app/src/main/res/anim/no_anim.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_anim_in.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_anim_up.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_fade_in.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_fade_out.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_show2bottom.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_top_in.xml create mode 100644 VisualNovel/app/src/main/res/anim/picker_top_out.xml create mode 100644 VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml create mode 100644 VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 VisualNovel/app/src/main/res/drawable/progress_recording.xml create mode 100644 VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml create mode 100644 VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml create mode 100644 VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml create mode 100644 VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml create mode 100644 VisualNovel/app/src/main/res/layout/button_with_icon.xml create mode 100644 VisualNovel/app/src/main/res/layout/dialog_double_btn.xml create mode 100644 VisualNovel/app/src/main/res/layout/dialog_loading.xml create mode 100644 VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml create mode 100644 VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml create mode 100644 VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml create mode 100644 VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml create mode 100644 VisualNovel/app/src/main/res/layout/fragment_main_actor.xml create mode 100644 VisualNovel/app/src/main/res/layout/fragment_main_book.xml create mode 100644 VisualNovel/app/src/main/res/layout/fragment_main_history.xml create mode 100644 VisualNovel/app/src/main/res/layout/fragment_main_manga.xml create mode 100644 VisualNovel/app/src/main/res/layout/item_album.xml create mode 100644 VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml create mode 100644 VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml create mode 100644 VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml create mode 100644 VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml create mode 100644 VisualNovel/app/src/main/res/layout/layout_empty.xml create mode 100644 VisualNovel/app/src/main/res/layout/layout_epal_crop.xml create mode 100644 VisualNovel/app/src/main/res/layout/layout_toolbar.xml create mode 100644 VisualNovel/app/src/main/res/layout/load_more_loading_view.xml create mode 100644 VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_activity_crop.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_activity_preview.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_folder_item.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_item_camera.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_item_root.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml create mode 100644 VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml create mode 100644 VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml create mode 100644 VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml create mode 100644 VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml create mode 100644 VisualNovel/app/src/main/res/layout/popwindow_tips.xml create mode 100644 VisualNovel/app/src/main/res/layout/view_load_more_common.xml create mode 100644 VisualNovel/app/src/main/res/layout/widget_item_like.xml create mode 100644 VisualNovel/app/src/main/res/layout/widget_lock_tag.xml create mode 100644 VisualNovel/app/src/main/res/layout/widget_price_view.xml create mode 100644 VisualNovel/app/src/main/res/layout/widget_user_avatar.xml create mode 100644 VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/icon_new_empty.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_off.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_on.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_off.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_on.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_off.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_history_on.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_off.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_on.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_expand.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_shrink.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/checkbox_normal.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_female.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_male.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_nonconforming.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_status_error.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_tips.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_checked.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_diamond.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked_disabled.webp create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fill.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_fit.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_full.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_haswhite.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_video.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video_mask.png create mode 100644 VisualNovel/app/src/main/res/mipmap-xxxhdpi/radio_normal.webp create mode 100644 VisualNovel/app/src/main/res/raw/like.json create mode 100644 VisualNovel/app/src/main/res/raw/single_ring.json create mode 100644 VisualNovel/app/src/main/res/values-night/themes.xml create mode 100644 VisualNovel/app/src/main/res/values/attrs.xml create mode 100644 VisualNovel/app/src/main/res/values/colors.xml create mode 100644 VisualNovel/app/src/main/res/values/dimens.xml create mode 100644 VisualNovel/app/src/main/res/values/iconfontkey.xml create mode 100644 VisualNovel/app/src/main/res/values/ids.xml create mode 100644 VisualNovel/app/src/main/res/values/strings.xml create mode 100644 VisualNovel/app/src/main/res/values/styles.xml create mode 100644 VisualNovel/app/src/main/res/values/themes.xml create mode 100644 VisualNovel/app/src/main/res/values/ui_tokens.xml create mode 100644 VisualNovel/app/src/main/res/xml/backup_rules.xml create mode 100644 VisualNovel/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 VisualNovel/app/src/test/java/com/remax/visualnovel/ExampleUnitTest.kt create mode 100644 VisualNovel/build.gradle.kts create mode 100644 VisualNovel/buildSrc/build.gradle.kts create mode 100644 VisualNovel/buildSrc/build/classes/kotlin/main/Deps$ClassPath.class create mode 100644 VisualNovel/buildSrc/build/classes/kotlin/main/Deps.class create mode 100644 VisualNovel/buildSrc/build/classes/kotlin/main/META-INF/buildSrc.kotlin_module create mode 100644 VisualNovel/buildSrc/build/classes/kotlin/main/Version.class create mode 100644 VisualNovel/buildSrc/build/kotlin/compileKotlin/cacheable/caches-jvm/lookups/counters.tab create mode 100644 VisualNovel/buildSrc/build/kotlin/compileKotlin/cacheable/last-build.bin create mode 100644 VisualNovel/buildSrc/build/kotlin/compileKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin create mode 100644 VisualNovel/buildSrc/build/libs/buildSrc.jar create mode 100644 VisualNovel/buildSrc/build/tmp/jar/MANIFEST.MF create mode 100644 VisualNovel/buildSrc/src/main/java/Deps.kt create mode 100644 VisualNovel/gradle.properties create mode 100644 VisualNovel/gradle/libs.versions.toml create mode 100644 VisualNovel/gradle/wrapper/gradle-wrapper.jar create mode 100644 VisualNovel/gradle/wrapper/gradle-wrapper.properties create mode 100644 VisualNovel/gradlew create mode 100644 VisualNovel/gradlew.bat create mode 100644 VisualNovel/loadingstateview-ktx/.gitignore create mode 100644 VisualNovel/loadingstateview-ktx/build.gradle.kts create mode 100644 VisualNovel/loadingstateview-ktx/consumer-rules.pro create mode 100644 VisualNovel/loadingstateview-ktx/proguard-rules.pro create mode 100644 VisualNovel/loadingstateview-ktx/src/main/java/com/dylanc/loadingstateview/BaseToolbarViewDelegate.kt create mode 100644 VisualNovel/loadingstateview-ktx/src/main/java/com/dylanc/loadingstateview/Decorative.kt create mode 100644 VisualNovel/loadingstateview-ktx/src/main/java/com/dylanc/loadingstateview/LoadingState.kt create mode 100644 VisualNovel/loadingstateview-ktx/src/main/java/com/dylanc/loadingstateview/LoadingStateDelegate.kt create mode 100644 VisualNovel/loadingstateview-ktx/src/main/java/com/dylanc/loadingstateview/ToolbarConfig.kt create mode 100644 VisualNovel/loadingstateview/.gitignore create mode 100644 VisualNovel/loadingstateview/build.gradle.kts create mode 100644 VisualNovel/loadingstateview/proguard-rules.pro create mode 100644 VisualNovel/loadingstateview/src/main/java/com/dylanc/loadingstateview/LoadingStateView.kt create mode 100644 VisualNovel/loadingstateview/src/main/res/values/strings.xml create mode 100644 VisualNovel/settings.gradle.kts create mode 100644 VisualNovel/viewbinding-base/.gitignore create mode 100644 VisualNovel/viewbinding-base/build.gradle.kts create mode 100644 VisualNovel/viewbinding-base/consumer-rules.pro create mode 100644 VisualNovel/viewbinding-base/proguard-rules.pro create mode 100644 VisualNovel/viewbinding-base/src/main/java/com/dylanc/viewbinding/base/FragmentBinding.kt create mode 100644 VisualNovel/viewbinding-base/src/main/java/com/dylanc/viewbinding/base/ViewBindingUtil.kt create mode 100644 VisualNovel/viewbinding-base/src/main/res/values/ids.xml create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/.gitignore create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/build.gradle.kts create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/consumer-rules.pro create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/proguard-rules.pro create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/src/main/java/com/dylanc/viewbinding/nonreflection/Dialog.kt create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/src/main/java/com/dylanc/viewbinding/nonreflection/Fragment.kt create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/src/main/java/com/dylanc/viewbinding/nonreflection/View.kt create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/src/main/java/com/dylanc/viewbinding/nonreflection/ViewGroup.kt create mode 100644 VisualNovel/viewbinding-nonreflection-ktx/src/main/res/values/ids.xml create mode 100644 VisualNovel/visual_noval_keystore diff --git a/VisualNovel/.gitignore b/VisualNovel/.gitignore new file mode 100644 index 0000000..3cb54ba --- /dev/null +++ b/VisualNovel/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +*/build/ +**/build/ +/captures +.externalNativeBuild +.cxx +local.properties \ No newline at end of file diff --git a/VisualNovel/.idea/.gitignore b/VisualNovel/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/VisualNovel/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/VisualNovel/.idea/codeStyles/Project.xml b/VisualNovel/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/VisualNovel/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/codeStyles/codeStyleConfig.xml b/VisualNovel/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/VisualNovel/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/compiler.xml b/VisualNovel/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/VisualNovel/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/deploymentTargetSelector.xml b/VisualNovel/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/VisualNovel/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/deviceManager.xml b/VisualNovel/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/VisualNovel/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/gradle.xml b/VisualNovel/.idea/gradle.xml new file mode 100644 index 0000000..8773fa4 --- /dev/null +++ b/VisualNovel/.idea/gradle.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/kotlinc.xml b/VisualNovel/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/VisualNovel/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/migrations.xml b/VisualNovel/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/VisualNovel/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/misc.xml b/VisualNovel/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/VisualNovel/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/runConfigurations.xml b/VisualNovel/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/VisualNovel/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/.idea/vcs.xml b/VisualNovel/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/VisualNovel/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/.gitignore b/VisualNovel/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/VisualNovel/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/VisualNovel/app/build.gradle.kts b/VisualNovel/app/build.gradle.kts new file mode 100644 index 0000000..9a66c87 --- /dev/null +++ b/VisualNovel/app/build.gradle.kts @@ -0,0 +1,283 @@ +import com.android.build.api.dsl.ApplicationProductFlavor + +plugins { + id("com.android.application") + kotlin("android") + id("kotlin-parcelize") + kotlin("kapt") + id("dagger.hilt.android.plugin") + //TODO - enable later: id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") + +} + +android { + namespace = Version.applicationId + compileSdk = 36 + + + defaultConfig { + applicationId = Version.applicationId + minSdk = Version.minSdk + targetSdk = Version.targetSdk + compileSdk = Version.targetSdk + versionCode = Version.versionCode + versionName = Version.versionName + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + setProperty("archivesBaseName", "${Version.applicationId}-build${Version.versionCode}") + multiDexEnabled = true + ndk { + abiFilters += listOf("armeabi", "armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + renderscriptTargetApi = 23 + renderscriptSupportModeEnabled = true + } + + kapt { + correctErrorTypes = true + arguments { + arg("AROUTER_MODULE_NAME", project.name) + } + } + + + + hilt { + enableExperimentalClasspathAggregation = true + enableAggregatingTask = false + } + + buildFeatures { + dataBinding = true + viewBinding = true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + signingConfigs { + val storeFileName = "storeFile" + val storePasswordName = "KEYSTORE_PWD" + val keyAliasName = "KEY_ALIAS" + val keyPasswordName = "KEY_PWD" + + fun names(suffix: String = "") = + listOf(storeFileName + suffix, storePasswordName + suffix, keyAliasName + suffix, keyPasswordName + suffix) + + create("release") { + storeFile = file(project.properties[names()[0]]?.toString() ?: "") + storePassword = project.properties[names()[1]] as? String? + keyAlias = project.properties[names()[2]] as? String? + keyPassword = project.properties[names()[3]] as? String? + } + getByName("debug") { + storeFile = file(project.properties[names()[0]]?.toString() ?: "") + storePassword = project.properties[names()[1]] as? String? + keyAlias = project.properties[names()[2]] as? String? + keyPassword = project.properties[names()[3]] as? String? + } + } + + + + buildTypes { + release { + isMinifyEnabled = false + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + isMinifyEnabled = false + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + targetCompatibility(JavaVersion.VERSION_17) + sourceCompatibility(JavaVersion.VERSION_17) + } + + + + val flavorDimensionName = "VisualNovel" + flavorDimensions.add(flavorDimensionName) + productFlavors { + fun ApplicationProductFlavor.buildConfigString(name: String, value: String) = + buildConfigField("String", name, "\"$value\"") + + fun ApplicationProductFlavor.buildConfigBoolean(name: String, value: String) = + buildConfigField("Boolean", name, value) + + create("novelTest") { + dimension = flavorDimensionName + signingConfig = signingConfigs.getByName("debug") + + buildConfigString("HOST", "https://www.xxxxx.ai/") + buildConfigString("ABOUT_US", "https://www.xxxxx.ai/about") + buildConfigString("API_FROG", "https://www.test-frog.xxxxx.ai") + buildConfigString("EPAL_TERMS_SERVICES", "https://www.xxxxx.ai/policy/tos") + } + + + create("product") { + dimension = flavorDimensionName + signingConfig = signingConfigs.getByName("release") + + buildConfigString("HOST", "https://test.xxxxx.ai/") + buildConfigString("ABOUT_US", "https://test.xxxxx.ai/about") + buildConfigString("API_FROG", "https://test-frog.xxxxx.ai") + buildConfigString("EPAL_TERMS_SERVICES", "https://test.xxxxx.ai/policy/tos") + } + } +} + +dependencies { + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar", "*.aar"))) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") + + coreLibraryDesugaring(Deps.desugar) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + + + + //dagger hilt + implementation(Deps.hilt) + kapt(Deps.hiltAndroidCompiler) + kapt(Deps.hiltCompiler) + + //Retrofit2 + implementation(Deps.retrofit2) + implementation(Deps.retrofitAdapterRxjava2) + implementation(Deps.retrofit2ConverterGson) + implementation(Deps.okhttp3LoggingInterceptor) + + + // timber used for: recording logs + implementation(Deps.timber) + + + // tecent mmkv + implementation(Deps.mmkv) + implementation(Deps.material) + + // gson + implementation(Deps.gson) + + // viewpager2 + implementation(Deps.viewpager2) + + // for multiple dex support + implementation(Deps.multidex) + + + //google firebase + implementation(platform(Deps.firebaseBom)) + implementation(Deps.firebaseMessageKtx) + implementation(Deps.firebaseAnalyticsKtx) + implementation(Deps.firebaseCrashlyticsKtx) + implementation(Deps.firebaseAuthKtx) + implementation(Deps.credentials) + implementation(Deps.credentialsAuth) + implementation(Deps.googleId) + + + // coroutine support + implementation(Deps.kotlinCoroutinesCore) + implementation(Deps.kotlinCoroutinesAndroid) + + implementation(Deps.appcompat) + implementation(Deps.constraintlayout) + implementation(Deps.flexbox) + implementation(Deps.coreKtx) + implementation(Deps.activityKtx) + implementation(Deps.activityCompose) + implementation(Deps.fragmentKtx) + + // vm and lifecycle + implementation(Deps.viewModel) + implementation(Deps.livedata) + implementation(Deps.lifecycleJava8) + implementation(Deps.lifecycleRuntime) + implementation(Deps.datastore) + + + //glide + implementation(Deps.glide) + kapt(Deps.glideCompiler) + implementation(Deps.glideTransformations) + implementation(Deps.glideWebpdecoder) + + // eventbus + implementation(Deps.modularEventbus) + kapt(Deps.modularEventbusCompiler) + + // indicator + implementation(Deps.magicIndicator) + + // BlurView and Luban + implementation(Deps.Luban) + implementation(Deps.BlurView) + + //ali aRouter + implementation(Deps.arouter) + kapt(Deps.arouterCompiler) + + + //Permission + implementation(Deps.permission) + // lottie + implementation(Deps.lottie) + // float window + implementation(Deps.easyFloat) + // guide + implementation(Deps.newbieGuide) + // refreshLayout + implementation(Deps.refreshLayout) + // apng + implementation(Deps.apng) + // softKey board + implementation(Deps.skbGlobal) + + + // baseRecyclerAdapter + implementation(Deps.brvah) + implementation(Deps.swipeMenuLayout) + implementation(Deps.BRV) + + //banner + implementation(Deps.banner) + + // spannable + implementation(Deps.spannablex) + + // Media related libs + implementation(Deps.mp3Recorder) + implementation(Deps.photoView) + implementation(Deps.transition) + implementation(Deps.paging) + implementation(Deps.exoplayer) + implementation(Deps.subsamplingScaleImageView) + + + + implementation(project(mapOf("path" to ":loadingstateview"))) + implementation(project(mapOf("path" to ":loadingstateview-ktx"))) + implementation(project(mapOf("path" to ":viewbinding-base"))) + implementation(project(mapOf("path" to ":viewbinding-nonreflection-ktx"))) +} \ No newline at end of file diff --git a/VisualNovel/app/libs/ninepatch-1.0.aar b/VisualNovel/app/libs/ninepatch-1.0.aar new file mode 100644 index 0000000000000000000000000000000000000000..1e5560470e09ed5ebf3f9e09fea03f58d7bdfe60 GIT binary patch literal 15004 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 T>8>IF8ppS#CsJIf#-Y3g$8NXC literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/assets/family/D-Din-700.ttf b/VisualNovel/app/src/main/assets/family/D-Din-700.ttf new file mode 100644 index 0000000000000000000000000000000000000000..95f91b35b68edb04aeca7afbc3dde8fcc28962a0 GIT binary patch literal 60188 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%0AC7VmlS@LZ-TEZV}oirP&oT11@@uCp8_l( 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 by lazy { + listOf( + BuildConfig.API_FROG, + BuildConfig.HOST + ).map { + it.parseHost() + } + } + + private fun String.parseHost() = this.toUri().host + + private val retrofit: Retrofit by lazy { + createRetrofit() + } + + private val okHttpClient: OkHttpClient by lazy { + createOkHttpClient() + } + + /** + * 构建OkHttpClient对象 + */ + private fun createOkHttpClient() = OkHttpClient.Builder() + .addInterceptor(GlobalInterceptor()) + .callTimeout(30, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .hostnameVerifier { hostname, _ -> + hostname in hostNameList + } + .apply { + if (BuildConfig.DEBUG) { + val interceptor = HttpLoggingInterceptor() + interceptor.level = HttpLoggingInterceptor.Level.BODY + addNetworkInterceptor(interceptor) + } + }.build() + + /** + * 构建Retrofit对象 + */ + private fun createRetrofit() = Retrofit.Builder() + .baseUrl(BuildConfig.API_FROG) + .client(okHttpClient) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + inline fun createService(): T = create(T::class.java) + + fun create(clazz: Class): T { + return retrofit.create(clazz) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt new file mode 100644 index 0000000..222d690 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/GlobalInterceptor.kt @@ -0,0 +1,89 @@ +package com.remax.visualnovel.api.interceptor + + +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.constant.AppConstant +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.AppUtils +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.asResponseBody +import okio.Buffer +import timber.log.Timber +import java.io.IOException + +/** + * Created by HJW on 2022/10/17 + */ +class GlobalInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + val bodyStr = readBody(chain.request().body) + val emptyBody = "{}".toRequestBody("application/json;charset=utf-8".toMediaType()) + val requestBody = if (bodyStr.isNotBlank()) request.body else emptyBody + +// val platform = "ANDROID_${BuildConfig.VERSION_NAME}" +// val userAgent = String.format( +// "E-Pal/%s (%s; android %s)", +// BuildConfig.VERSION_NAME, +// Build.MODEL, +// Build.VERSION.RELEASE +// ) + + val headersBuilder = Headers.Builder() + .add("AUTH_TK", LoginManager.token ?: "") + .add("AUTH_DID", AppUtils.getAndroidID()) + .add("platform", AppConstant.APP_CLIENT) + .add("versionNum", "100") + + val headers = headersBuilder.build() + + request = chain.request().newBuilder() + .headers(headers) + .post(requestBody ?: emptyBody) + .build() + + val response = chain.proceed(request) + + if (BuildConfig.DEBUG) { + try { + Timber.tag("发起请求") + Timber.d( + """ + ———————————————— 我是开始分割线 —————————————————————————————— + ${request.url} + 入参: + ${readBody(requestBody)} + 响应: + ${clone(response.body)?.string()} + ———————————————— 我是结束分割线 ——————————————————————————————— + """.trimIndent() + ) + } catch (e: Exception) { + Timber.e("GlobalInterceptor request.exception : ${e.localizedMessage}}") + } + } + + return response + } + + private fun readBody(body: RequestBody?): String { + val buffer = Buffer() + body?.writeTo(buffer) + return buffer.readUtf8() + } + + @Throws(IOException::class) + private fun clone(body: ResponseBody?): ResponseBody? { + val source = body?.source() + if (source?.request(Long.MAX_VALUE) == true) throw IOException("body too long!") + val bufferedCopy = source?.buffer?.clone() + return bufferedCopy?.asResponseBody(body.contentType(), body.contentLength()) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java new file mode 100644 index 0000000..d273012 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Base64.java @@ -0,0 +1,102 @@ +package com.remax.visualnovel.api.interceptor.util; + +import java.io.UnsupportedEncodingException; + +public class Base64 { + + private static final char[] ENCODE_CHARS = new char[]{'A', 'B', 'C', 'D', + 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', + 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', + 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', + 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', '+', '/'}; + + private static final byte[] DECODE_CHARS = new byte[]{-1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, + 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, + -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, + 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, + -1, -1}; + + public static String encode(byte[] data) { + StringBuffer buffer = new StringBuffer(); + int length = data.length; + int indexx = 0; + int b1, b2, b3; + while (indexx < length) { + b1 = data[indexx++] & 0xff; + if (indexx == length) { + buffer.append(ENCODE_CHARS[b1 >>> 2]); + buffer.append(ENCODE_CHARS[(b1 & 0x3) << 4]); + buffer.append("=="); + break; + } + b2 = data[indexx++] & 0xff; + if (indexx == length) { + buffer.append(ENCODE_CHARS[b1 >>> 2]); + buffer.append(ENCODE_CHARS[((b1 & 0x03) << 4) | ((b2 & 0xf0) >>> 4)]); + buffer.append(ENCODE_CHARS[(b2 & 0x0f) << 2]); + buffer.append("="); + break; + } + b3 = data[indexx++] & 0xff; + buffer.append(ENCODE_CHARS[b1 >>> 2]); + buffer.append(ENCODE_CHARS[((b1 & 0x03) << 4) | ((b2 & 0xf0) >>> 4)]); + buffer.append(ENCODE_CHARS[((b2 & 0x0f) << 2) | ((b3 & 0xc0) >>> 6)]); + buffer.append(ENCODE_CHARS[b3 & 0x3f]); + } + return buffer.toString(); + } + + public static byte[] decode(String str) throws UnsupportedEncodingException { + StringBuffer sb = new StringBuffer(); + byte[] data = str.getBytes("US-ASCII"); + int len = data.length; + int i = 0; + int b1, b2, b3, b4; + while (i < len) { + do { + b1 = DECODE_CHARS[data[i++]]; + } while (i < len && b1 == -1); + if (b1 == -1) { + break; + } + + do { + b2 = DECODE_CHARS[data[i++]]; + } while (i < len && b2 == -1); + if (b2 == -1) { + break; + } + sb.append((char) ((b1 << 2) | ((b2 & 0x30) >>> 4))); + + do { + b3 = data[i++]; + if (b3 == 61) { + return sb.toString().getBytes("iso8859-1"); + } + b3 = DECODE_CHARS[b3]; + } while (i < len && b3 == -1); + if (b3 == -1) { + break; + } + sb.append((char) (((b2 & 0x0f) << 4) | ((b3 & 0x3c) >>> 2))); + + do { + b4 = data[i++]; + if (b4 == 61) { + return sb.toString().getBytes("iso8859-1"); + } + b4 = DECODE_CHARS[b4]; + } while (i < len && b4 == -1); + if (b4 == -1) { + break; + } + sb.append((char) (((b3 & 0x03) << 6) | b4)); + } + return sb.toString().getBytes("iso8859-1"); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java new file mode 100644 index 0000000..1c6b222 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/interceptor/util/Md5.java @@ -0,0 +1,37 @@ +package com.remax.visualnovel.api.interceptor.util; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Md5 { + + private Md5() { + } + + public static String encode(String str) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(str.getBytes()); + byte[] byteDigest = md.digest(); + StringBuilder buf = new StringBuilder(); + + for (int b : byteDigest) { + int i = b; + if (i < 0) { + i += 256; + } + + if (i < 16) { + buf.append("0"); + } + + buf.append(Integer.toHexString(i)); + } + + return buf.toString(); + } catch (NoSuchAlgorithmException var6) { + var6.printStackTrace(); + return ""; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt new file mode 100644 index 0000000..dfc9a44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/BookService.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.POST + + +interface BookService { + @POST("/web/si/asi") + suspend fun getBooks(): Response + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt new file mode 100644 index 0000000..343dd38 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/DictService.kt @@ -0,0 +1,33 @@ +package com.remax.visualnovel.api.service + + +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface DictService { + + /** + * 获取聊天气泡字典 + */ + /*@POST("/web/chat-set/get-chat-bubble-list") + suspend fun getChatBubbleList(@Body request: AIIDRequest): Response>*/ + + /** + * AI标签 + */ + /*@POST("/web/get-ai-dict") + suspend fun getAIDict(): Response*/ + + /** + * 礼物字典 + */ + /*@POST("/web/gift/dict-list") + suspend fun getGiftDict(@Body pageQuery: PageQuery = PageQuery(1).apply { page.ps = 100 }): Response>*/ + + /** + * chat模型 + */ + /*@POST("/web/chat-model/dict-list") + suspend fun getAIChatModel(): Response>*/ +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt new file mode 100644 index 0000000..3592999 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/LoginService.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.entity.request.PlatformAccountVerifyDTO +import com.remax.visualnovel.entity.response.PlatformAccountVerify +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface LoginService { + + /** + * 重复昵称验证 + */ + @POST("/web/user/nickname-check") + suspend fun checkUserNickname(@Body request: CompleteUserInfoInput): Response + + /** + * 三方账号验证 + */ + @POST("/web/third/login") + suspend fun platformThirdVerify(@Body request: PlatformAccountVerifyDTO): Response + + @POST("/web/user/logout") + suspend fun logout(): Response + + @POST("/web/user/complete-user-info") + suspend fun register(@Body request: CompleteUserInfoInput): Response + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt new file mode 100644 index 0000000..fcba604 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/MessageService.kt @@ -0,0 +1,34 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface MessageService { + +// /** +// * 删除会话 +// */ +// @POST(BuildConfig.API_COW + "/web/ai-message/del") +// suspend fun deleteConversation(@Body request: AIListRequest): Response +// +// /** +// * 送礼物 +// */ +// @POST("/web/ai-user-gift/send") +// suspend fun sendGift(@Body dto: SendGift): Response +// +// /** +// * 未读消息统计 +// */ +// @POST(BuildConfig.API_PIGEON + "/web/message/stat") +// suspend fun getMessageStat(): Response +// +// /** +// * 系统通知列表 +// */ +// @POST(BuildConfig.API_PIGEON + "/web/message/list") +// suspend fun getMessageList(@Body dto: PageQuery): Response> + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt new file mode 100644 index 0000000..a295567 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/api/service/UserService.kt @@ -0,0 +1,52 @@ +package com.remax.visualnovel.api.service + +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.entity.response.Character +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.entity.response.base.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface UserService{ + + /** + * 签到 + */ + @POST("/web/si/asi") + suspend fun signToday(): Response + + /** + * 获取签到周期数据 + */ + /*@POST("/web/si/list") + suspend fun getSignList(): Response*/ + + /** + * 获取登录用户基础信息 + */ + @POST("/web/user/base-info") + suspend fun getMyBaseInfo(): Response + + /** + * 获取me页面的ai列表 + */ + @POST("/web/ai-user-search/base-list") + suspend fun getMyCharactersList(): Response> + + /** + * 删除账号 + */ + @POST("/web/user/del") + suspend fun deleteAccount(): Response + + @POST("/web/user/edit-user-info") + suspend fun updateUserInfo(@Body request: CompleteUserInfoInput): Response + + /** + * 获取云信appKey account token + */ + /*@POST(BuildConfig.API_PIGEON + "/web/im-user/get-account") + suspend fun getNimInfo(): Response*/ + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt new file mode 100644 index 0000000..86d99c0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/ProcessLifecycleObserver.kt @@ -0,0 +1,45 @@ +package com.remax.visualnovel.app + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.remax.visualnovel.app.initializer.utils.FirebaseHelper +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel + + +/** + * Created by HJW on 2022/9/21 + * 监听整个app生命周期 + */ +object ProcessLifecycleObserver : DefaultLifecycleObserver { + + var isOnResume = false + private var refreshFirebaseTokenTime = 0L + + private var appGlobalViewModel: AppGlobalViewModel? = null + + fun setAppGlobalViewModel(appGlobalViewModel: AppGlobalViewModel) { + ProcessLifecycleObserver.appGlobalViewModel = appGlobalViewModel + } + + /** + * APP在前台回调 + */ + override fun onResume(owner: LifecycleOwner) { + isOnResume = true + val currTime = System.currentTimeMillis() + if (refreshFirebaseTokenTime != 0L && currTime - refreshFirebaseTokenTime > 60 * 1000) { + refreshFirebaseTokenTime = currTime + FirebaseHelper.getToken { + appGlobalViewModel?.updateTerminal(it) + } + } + + } + + /** + * APP进入后台回调 + */ + override fun onPause(owner: LifecycleOwner) { + isOnResume = false + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt new file mode 100644 index 0000000..faec652 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/activityresultapi/XActivityResultContract.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.app.activityresultapi + +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract + +class XActivityResultContract( + activityResultCaller: ActivityResultCaller, + activityResultContract: ActivityResultContract +) { + + private var activityResultCallback: ActivityResultCallback? = null + + + private val launcher: ActivityResultLauncher = + activityResultCaller.registerForActivityResult(activityResultContract) { + activityResultCallback?.onActivityResult(it) + } + + + /** + * 启动 + */ + fun launch(input: I, activityResultCallback: ActivityResultCallback?) { + this.activityResultCallback = activityResultCallback + launcher.launch(input) + } + + /** + * 注销 + */ + fun unregister() { + launcher.unregister() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt new file mode 100644 index 0000000..9db3910 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingActivity.kt @@ -0,0 +1,144 @@ +package com.remax.visualnovel.app.base + +import android.content.res.Resources +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding +import com.remax.visualnovel.app.AbsView +import com.remax.visualnovel.app.widget.LoadingDialog +import com.remax.visualnovel.extension.fixedFontSize +import com.remax.visualnovel.extension.getBgColor +import com.remax.visualnovel.extension.isShouldHideKeyboard +import com.remax.visualnovel.extension.setStatusBarColor +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.extension.transitionFromAlpha +import com.remax.visualnovel.extension.transitionFromBottom +import com.remax.visualnovel.extension.withTransitionFromAlpha +import com.remax.visualnovel.extension.withTransitionFromBottom +import com.remax.visualnovel.utils.KeyboardUtils +import com.remax.visualnovel.utils.StatusBarUtils +import com.dylanc.loadingstateview.ActivityTransitionType +import com.dylanc.loadingstateview.BgColorType +import com.dylanc.loadingstateview.Decorative +import com.dylanc.loadingstateview.LoadingState +import com.dylanc.loadingstateview.LoadingStateDelegate +import com.dylanc.loadingstateview.OnReloadListener +import com.dylanc.viewbinding.base.ActivityBinding +import com.dylanc.viewbinding.base.ActivityBindingDelegate + + +/** + * Activity基类 + */ +abstract class BaseBindingActivity : AppCompatActivity(), AbsView, + LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative, + ActivityBinding by ActivityBindingDelegate() { + + private val loadingDialog by lazy { + createLoadingDialog() + } + + private fun createLoadingDialog(): LoadingDialog { + val dialog = LoadingDialog() + dialog.build(this) + return dialog + } + + override fun getResources(): Resources { + return super.getResources().fixedFontSize(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentViewWithBinding() + binding.root.decorate(this, this) + StatusBarUtils.setStatusBarAndNavBarIsLight(this, false) + setStatusBarColor(backgroundColorType()) + binding.root.setBackgroundColor(getBgColor(backgroundColorType())) + when (transitionType()) { + ActivityTransitionType.BOTTOM -> withTransitionFromBottom() + ActivityTransitionType.ALPHA -> withTransitionFromAlpha() + else -> {} + } + initView() + initData() + subscribeUi() + } + + protected open fun backgroundColorType() = BgColorType.SPECIAL_MAP + + protected abstract fun initView() + protected open fun subscribeUi() {} + protected open fun initData() {} + + override fun onResume() { + super.onResume() + } + + override fun onPause() { + super.onPause() + if (KeyboardUtils.isSoftInputVisible(this)) { + KeyboardUtils.hideSoftInput(this) + } + } + + override fun finish() { + super.finish() + when (transitionType()) { + ActivityTransitionType.BOTTOM -> transitionFromBottom() + ActivityTransitionType.ALPHA -> transitionFromAlpha() + else -> {} + } + } + + protected open var touchEventFun: ((MotionEvent) -> Unit)? = null + protected open fun touchHideKeyboardViewList(): List? = null + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + touchEventFun?.let { + runCatching { ev?.run(it) } + } + if (touchHideKeyboardViewList() != null) { + if (ev?.action == MotionEvent.ACTION_DOWN && KeyboardUtils.isSoftInputVisible(this) && touchHideKeyboardViewList()?.any { + it.isShouldHideKeyboard( + ev + ) + } == true) { + KeyboardUtils.hideSoftInput(this) + } + } + return super.dispatchTouchEvent(ev) + } + + /** + * 页面开关方向 + */ + protected open fun transitionType() = ActivityTransitionType.DEFAULT + + override fun showLoading() { + if (!this.isDestroyed) { + runOnUiThread { + loadingDialog.show() + } + } + } + + override fun hideLoading() { + if (!this.isDestroyed) { + runOnUiThread { + loadingDialog.dismiss() + } + } + } + + override fun showToast(text: String?) { + toast(text) + } + + override fun showToast(resId: Int) { + toast(resId) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt new file mode 100644 index 0000000..bb2c129 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseBindingFragment.kt @@ -0,0 +1,112 @@ +package com.remax.visualnovel.app.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.dylanc.loadingstateview.BgColorType +import com.dylanc.loadingstateview.Decorative +import com.dylanc.loadingstateview.LoadingState +import com.dylanc.loadingstateview.LoadingStateDelegate +import com.dylanc.loadingstateview.OnReloadListener +import com.dylanc.viewbinding.base.FragmentBinding +import com.dylanc.viewbinding.base.FragmentBindingDelegate +import com.remax.visualnovel.app.AbsView +import com.remax.visualnovel.app.widget.LoadingDialog +import com.remax.visualnovel.extension.getBgColor +import com.remax.visualnovel.extension.toast + +/** + * 基类Fragment + */ +abstract class BaseBindingFragment : Fragment(), AbsView, + LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative, + FragmentBinding by FragmentBindingDelegate() { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = createViewWithBinding(inflater, container).decorate(this, this) + view.setBackgroundColor(requireContext().getBgColor(backgroundColorType())) + return view + } + + open fun backgroundColorType() = BgColorType.SPECIAL_MAP + + private val loadingDialog by lazy { + createLoadingDialog() + } + + private var isLoaded = false + + private fun createLoadingDialog(): LoadingDialog { + val dialog = LoadingDialog() + dialog.build(requireActivity()) + return dialog + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onCreated(arguments) + subscribeUi() + } + + override fun onResume() { + super.onResume() + if (!isLoaded && !isHidden) { + lazyInit() + isLoaded = true + } + } + + abstract fun onCreated(bundle: Bundle?) + + open fun lazyInit() {} + + open fun subscribeUi() {} + + override fun showLoading() { + if (activity?.isDestroyed == false) { + activity?.runOnUiThread { + loadingDialog.show() + } + } + } + + override fun hideLoading() { + if (activity?.isDestroyed == false) { + activity?.runOnUiThread { + loadingDialog.dismiss() + } + } + } + + override fun showToast(text: String?) { + if (!isDetached) { + activity?.let { + if (!it.isDestroyed) { + it.toast(text) + } + } + } + } + + override fun showToast(resId: Int) { + if (!isDetached) { + activity?.let { + if (!it.isDestroyed) { + it.toast(resId) + } + } + } + } + + override fun onDetach() { + if (loadingDialog.getDialog().isShowing) { + loadingDialog.dismiss() + } + super.onDetach() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt new file mode 100644 index 0000000..b5a6794 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/BaseCommonNavigatorAdapter.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.app.base + +import androidx.viewpager.widget.ViewPager +import androidx.viewpager2.widget.ViewPager2 +import net.lucode.hackware.magicindicator.buildins.commonnavigator.abs.CommonNavigatorAdapter + +/** + * Created by HJW on 2023/7/18 + */ +abstract class BaseCommonNavigatorAdapter( + open val viewPager2: ViewPager2?, + open val viewPager: ViewPager? = null +) : CommonNavigatorAdapter() \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt new file mode 100644 index 0000000..f4df7e6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/AppViewModelFactory.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.app.base.app + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel +import com.remax.visualnovel.app.viewmodel.AppIMViewModel +import com.remax.visualnovel.repository.api.MessageRepository +import com.remax.visualnovel.repository.api.UserRepository +import javax.inject.Inject + +/** + * 用于创建[AppIMViewModel, AppGlobalViewModel]等实例 + */ +class AppViewModelFactory @Inject constructor( + private val application: Application, + private val userRepository: UserRepository, + private val messageRepository: MessageRepository +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + return when (modelClass) { + AppIMViewModel::class.java -> AppIMViewModel(application) + AppGlobalViewModel::class.java -> AppGlobalViewModel(application, userRepository, messageRepository) + else -> throw IllegalArgumentException("Unknown class $modelClass") + } as T + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt new file mode 100644 index 0000000..4167748 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/ApplicationProxy.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.app.base.app + +import android.app.Application + +interface ApplicationProxy { + + fun onCreate(application: Application) + + fun onTerminate() + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt new file mode 100644 index 0000000..95f199d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/base/app/CommonApplicationProxy.kt @@ -0,0 +1,22 @@ +package com.remax.visualnovel.app.base.app + +import android.app.Application +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +object CommonApplicationProxy : ApplicationProxy, ViewModelStoreOwner { + + lateinit var application: Application + private set + + override val viewModelStore = ViewModelStore() + + override fun onCreate(application: Application) { + CommonApplicationProxy.application = application + } + + override fun onTerminate() { + viewModelStore.clear() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt new file mode 100644 index 0000000..8d64ef3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/delegate/ToolbarViewDelegate.kt @@ -0,0 +1,264 @@ +package com.remax.visualnovel.app.delegate + +import android.app.Activity +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.LayoutToolbarBinding +import com.remax.visualnovel.extension.findActivityContext +import com.remax.visualnovel.extension.getBgColor +import com.remax.visualnovel.extension.getNavHeight +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.StatusBarUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.buttons.IconButtonView +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.dylanc.loadingstateview.BaseToolbarViewDelegate +import com.dylanc.loadingstateview.NavBtnType +import com.dylanc.loadingstateview.ToolbarConfig +import com.dylanc.loadingstateview.toolbarExtras + +var ToolbarConfig.titleTextColorToken: Int? by toolbarExtras() +var ToolbarConfig.titleTextColor: Int? by toolbarExtras() +var ToolbarConfig.titleTextAlpha: Float? by toolbarExtras() + +/** + * 整个导航栏相关 + */ +var ToolbarConfig.navBgColorToken: Int? by toolbarExtras() +var ToolbarConfig.navBgColor: Int? by toolbarExtras() +var ToolbarConfig.navBgAlpha: Float? by toolbarExtras() //一般和isFull = true 配合使用,因为只有当navbar和content布局重叠时,才需要滑动渐隐导航栏 +var ToolbarConfig.navIsShow: Boolean? by toolbarExtras() + +var ToolbarConfig.confirmEnabled: Boolean? by toolbarExtras() +var ToolbarConfig.isFull: Boolean? by toolbarExtras() +var ToolbarConfig.confirmContent: String? by toolbarExtras() +var ToolbarConfig.contractUSEnabled: Boolean? by toolbarExtras() + +/** + * 返回键相关 + */ +var ToolbarConfig.navBackColorToken: Int? by toolbarExtras() +var ToolbarConfig.navBackColor: Int? by toolbarExtras() +var ToolbarConfig.onNavBackClick: (() -> Unit)? by toolbarExtras() +var ToolbarConfig.onNavBackIconToken: Int? by toolbarExtras() + +var ToolbarConfig.iconButtonType: IconButtonType? by toolbarExtras() + + +enum class IconButtonType { + ON_PIC, NORMAL +} + +/** + * 公共导航栏 + */ +class ToolbarViewDelegate : BaseToolbarViewDelegate() { + private lateinit var binding: LayoutToolbarBinding + private var context: Context? = null + + override fun onCreateToolbar(inflater: LayoutInflater, parent: ViewGroup): View { + context = parent.context + binding = LayoutToolbarBinding.inflate(inflater, parent, false) + return binding.root + } + + override fun onBindToolbar(config: ToolbarConfig) { + binding.apply { + val expendSize = 4.dp + tvTitle.text = config.title?.ifEmpty { " " } ?: " " + context?.run { + if (config.isFull == true) { + root.tag = "isFull" + (context?.findActivityContext() as? Activity)?.let { + StatusBarUtils.setTransparent(it) + navBg.setSize(height = it.getNavHeight()) + } + navBg.alpha = 0f + tvTitle.alpha = 0f + } + //设置导航栏是否显示 + config.navIsShow?.let { root.isVisible = it } + navBg.setBackgroundColor(getBgColor(config.colorType)) + //设置导航栏颜色 + config.navBgColorToken?.let { navBg.setBackgroundColor(handleUIToken(it)?.color ?: 0) } + config.navBgColor?.let { navBg.setBackgroundColor(it) } + //设置导航栏透明度 + config.navBgAlpha?.let { navBg.alpha = it } + + //设置标题颜色 + config.titleTextColorToken?.let { tvTitle.setTextColor(handleUIToken(it)?.color ?: 0) } + config.titleTextColor?.let { tvTitle.setTextColor(it) } + //设置标题透明度 + config.titleTextAlpha?.let { tvTitle.alpha = it } + + //设置返回按钮颜色 + config.navBackColorToken?.let { navBack.changeTextColor { textUIColorToken = getString(it) } } + config.navBackColor?.let { navBack.setTextColor(it) } + + //设置confirm按钮是否可点 + config.confirmEnabled?.let { rightConfirmBtn.isEnabled = it } + //设置confirm按钮的文字 + config.confirmContent?.let { rightConfirmBtn.text = it } + //设置contract us按钮是否可点 + config.contractUSEnabled?.let { contractUsBtn.isEnabled = it } + + //设置iconfont按钮样式 + when (config.iconButtonType) { + IconButtonType.ON_PIC -> { + navBack.setButtonStyle(buttonName = IconButtonView.NavButton_OnPic) + rightIconBtn1.setButtonStyle(buttonName = IconButtonView.NavButton_OnPic) + rightIconBtn2.setButtonStyle(buttonName = IconButtonView.NavButton_OnPic) + } + + else -> { + navBack.setButtonStyle(buttonName = IconButtonView.NavButton) + rightIconBtn1.setButtonStyle(buttonName = IconButtonView.NavButton) + rightIconBtn2.setButtonStyle(buttonName = IconButtonView.NavButton) + } + } + + navBack.expand(expendSize, expendSize) + } + + val setBackClick = { + setOnClick(navBack) { + if (config.onNavBackClick != null) { + config.onNavBackClick?.invoke() + } else { + (context.findActivityContext() as? Activity)?.onBackPressed() + } + } + } + + when (config.navBtnType) { + //navBack是返回按钮 + NavBtnType.BACK -> { + navBack.setText(R.string.icon_arrow_left_border) + setBackClick.invoke() + navBack.isVisible = true + } + //navBack是关闭按钮 + NavBtnType.ClOSE -> { + navBack.setText(R.string.icon_close) + setBackClick.invoke() + navBack.isVisible = true + } + + //navBack是向下关闭按钮 + NavBtnType.DOWN -> { + navBack.setText(R.string.icon_arrow_down_border) + setBackClick.invoke() + navBack.isVisible = true + } + + //navBack是自定义按钮 + NavBtnType.CUSTOM -> { + if (config.onNavBackIconToken != null) { + navBack.setText(config.onNavBackIconToken!!) + navBack.isVisible = true + setBackClick.invoke() + } else { + navBack.isInvisible = true + } + } + + NavBtnType.INVISIBLE -> { + navBack.isInvisible = true + } + + NavBtnType.NONE -> { + navBack.isVisible = false + } + } + + with(contractUsBtn) { + if (config.contractUsBtnText != null) { + isVisible = true + val expandX = 24.dp + val expandY = 10.dp + expand(expandX, expandY) + if (config.contractUsBtnText != null) { + setText(config.contractUsBtnText ?: 0) + } + setOnClick(this) { + config.onContractUsBtnBlock?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + with(rightConfirmBtn) { + if (config.showConfirmBtn == true) { + isVisible = true + setOnClick(this) { + config.onConfirmBtnBlock?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + with(rightIconBtn1) { + if (config.showRightIconBtn1 != null) { + isVisible = true + setText(config.showRightIconBtn1!!) + expand(expendSize, expendSize) + setOnClick(this) { + config.onRightIconBtnBlock1?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + + with(rightIconBtn2) { + if (config.showRightIconBtn2 != null) { + isVisible = true + setText(config.showRightIconBtn2!!) + changeTextColor { + textUIColorToken = if (config.rightIconColorBtn2 != null) { + context.getString(config.rightIconColorBtn2!!) + } else { + context.getString(R.string.color_txt_primary_normal) + } + } + + expand(expendSize, expendSize) + setOnClick(this) { + config.onRightIconBtnBlock2?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + with(rightBtn) { + if (config.showRightBtn != null) { + isVisible = true + setText(config.showRightBtn!!) + setOnClick(this) { + config.onRightBtnBlock?.invoke(this) + } + } else { + isVisible = false + setOnClickListener(null) + } + } + + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt new file mode 100644 index 0000000..0420e5d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ApiServiceModule.kt @@ -0,0 +1,47 @@ +package com.remax.visualnovel.app.di + + +import com.remax.visualnovel.api.factory.ServiceFactory +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.api.service.DictService +import com.remax.visualnovel.api.service.LoginService +import com.remax.visualnovel.api.service.MessageService +import com.remax.visualnovel.api.service.UserService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * hilt注入service请求 + */ +@Module +@InstallIn(SingletonComponent::class) +object ApiServiceModule { + + @Singleton + @Provides + fun userService() = create() + + @Singleton + @Provides + fun loginService() = create() + + @Singleton + @Provides + fun messageService() = create() + + @Singleton + @Provides + fun dictService() = create() + + @Singleton + @Provides + fun bookService() = create() + + + private inline fun create(): T { + return ServiceFactory.createService() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt new file mode 100644 index 0000000..057ce13 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/di/ViewModelModule.kt @@ -0,0 +1,31 @@ +package com.remax.visualnovel.app.di + +import androidx.lifecycle.ViewModelProvider +import com.remax.visualnovel.app.base.app.AppViewModelFactory +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel +import com.remax.visualnovel.app.viewmodel.AppIMViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * [AppIMViewModel] 提供者 + */ +@InstallIn(SingletonComponent::class) +@Module +object ViewModelModule { + + @Singleton + @Provides + fun provideAppIMViewModel(factory: AppViewModelFactory) = + ViewModelProvider(CommonApplicationProxy.viewModelStore, factory)[AppIMViewModel::class.java] + + @Singleton + @Provides + fun provideAppGlobalViewModel(factory: AppViewModelFactory) = + ViewModelProvider(CommonApplicationProxy.viewModelStore, factory)[AppGlobalViewModel::class.java] + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt new file mode 100644 index 0000000..75eae27 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializerStartType.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.app.initializer + +/** + * Created by HJW on 2023/5/11 + * + * 启动类型 + */ +enum class AppInitializerStartType { + /** + * 串行执行 + */ + TYPE_SERIES, + + /** + * 并发执行 + */ + TYPE_PARALLEL, +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt new file mode 100644 index 0000000..14a7989 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializers.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.app.initializer + +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority + + +/** + * Created by HJW on 2023/5/11 + * 后续都需要依赖此框架 + */ +interface AppInitializers { + /** + * 初始化代码 + */ + fun init() + + /** + * @return 初始化类型 + */ + fun getStartType(): AppInitializerStartType = AppInitializerStartType.TYPE_SERIES + + /** + * TYPE_SERIES 类型时,权重越大,越先执行 + */ + fun widget(): Int = AppInitializerPriority.SERIES_1.priority +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt new file mode 100644 index 0000000..25cbd23 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/AppInitializersProvider.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.app.initializer + +import android.app.Application +import android.util.Log +import com.remax.visualnovel.app.initializer.di.AppInitializerEntryPoint +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Created by HJW on 2023/5/11 + */ +class AppInitializersProvider @Inject constructor(private val application: Application) { + + private val TAG = "AppInitializersProvider" + + private val initializers: Set by lazy { + EntryPointAccessors.fromApplication(application, AppInitializerEntryPoint::class.java) + .getAppInitializers() + } + + fun startInit() { + val seriesList = initializers.filter { + it.getStartType() == AppInitializerStartType.TYPE_SERIES + }.sortedByDescending { it.widget() } + val parallelList = + initializers.filter { it.getStartType() == AppInitializerStartType.TYPE_PARALLEL } + Log.d(TAG, "AppInitializersProvider 开始执行 并行") + parallelList.parallelStream().forEach { + MainScope().launch { + Log.d(TAG, "AppInitializersProvider $it 初始化模块协程开始执行: ${this.coroutineContext}") + it.init() + } + } + Log.d(TAG, "AppInitializersProvider 结束执行 并行") + seriesList.forEach { + it.init() + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt new file mode 100644 index 0000000..6ccd258 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerEntryPoint.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.app.initializer.di + + +import com.remax.visualnovel.app.initializer.AppInitializers +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Created by HJW on 2023/5/11 + * 启动框架容器注解 + */ + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AppInitializerEntryPoint { + fun getAppInitializers(): Set +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt new file mode 100644 index 0000000..b53acda --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializerPriority.kt @@ -0,0 +1,19 @@ +package com.remax.visualnovel.app.initializer.di + +/** + * Created by HJW on 2023/6/12 + */ +enum class AppInitializerPriority(val priority: Int) { + /** + * 并发,无优先级 + */ + PARALLEL(1), + + /** + * 串行,数字越大,优先级越高 + */ + SERIES_1(1), + SERIES_2(2), + SERIES_3(3), + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt new file mode 100644 index 0000000..d7f973f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/di/AppInitializersModule.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.app.initializer.di + +import android.app.Application +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.impl.ActivityLifecycleInitializer +import com.remax.visualnovel.app.initializer.impl.LocalDataInitializer +import com.remax.visualnovel.app.initializer.impl.RouterInitializer +import com.remax.visualnovel.app.initializer.impl.SystemInitializer +import com.remax.visualnovel.app.initializer.impl.ThirdInitializer +import com.remax.visualnovel.app.initializer.impl.UserInitializer +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel +import com.remax.visualnovel.app.viewmodel.AppIMViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Created by HJW on 2023/5/11 + * + * 添加容器注入 + */ +@Module +@InstallIn(SingletonComponent::class) +object AppInitializersModule { + + @Provides + fun provideAppInitializers(application: Application, + appIMViewModel: AppIMViewModel, + appGlobalViewModel: AppGlobalViewModel + ): Set = setOf( + // FirebaseInitializer(application), TODO- add firebase support later + UserInitializer(application), + //JsInitializer(application), + LocalDataInitializer(application), + RouterInitializer(application), + ThirdInitializer(application), + ActivityLifecycleInitializer(application, appIMViewModel), + SystemInitializer(application, appGlobalViewModel) + ) + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt new file mode 100644 index 0000000..f7c9965 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/LocalDataInitializer.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority +import com.remax.visualnovel.utils.datastore.IDataStoreOwner +import com.tencent.mmkv.MMKV + +/** + * Created by HJW on 2023/5/11 + * 本地数据保存相关初始化 + */ +class LocalDataInitializer(val application: Application) : AppInitializers { + + override fun init() { + //初始化mmkv + val dir = application.filesDir.absolutePath + "/mmkv_epal" + MMKV.initialize(application, dir) + //初始化datastore + IDataStoreOwner.application = application + } + + override fun widget(): Int { + return AppInitializerPriority.SERIES_3.priority + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt new file mode 100644 index 0000000..d5d39eb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/RouterInitializer.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import com.alibaba.android.arouter.launcher.ARouter +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority + +/** + * Created by HJW on 2023/5/11 + * 路由相关初始化 + */ +class RouterInitializer(val application: Application) : AppInitializers { + + override fun init() { + if (BuildConfig.DEBUG) { + ARouter.openLog() + ARouter.openDebug() + } + ARouter.init(application) + } + + override fun widget(): Int { + return AppInitializerPriority.SERIES_3.priority + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt new file mode 100644 index 0000000..c7223be --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/SystemInitializer.kt @@ -0,0 +1,47 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import android.os.Build +import android.webkit.WebView +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ProcessLifecycleOwner +import com.remax.visualnovel.app.ProcessLifecycleObserver +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.initializer.di.AppInitializerPriority +import com.remax.visualnovel.app.viewmodel.AppGlobalViewModel + +/** + * Created by HJW on 2023/5/11 + * 系统相关初始化 + */ +class SystemInitializer(val application: Application, val appGlobalViewModel: AppGlobalViewModel) : AppInitializers { + + override fun init() { + //监听application生命周期 + ProcessLifecycleOwner.get().lifecycle.addObserver(ProcessLifecycleObserver) + ProcessLifecycleObserver.setAppGlobalViewModel(appGlobalViewModel) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + //启用矢量图兼容 + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + var processName = "" + (application.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager) + ?.runningAppProcesses + ?.asSequence() + ?.forEach { processInfo -> + if (processInfo.pid == android.os.Process.myPid()) { + processName = processInfo.processName + } + } + if (processName != application.packageName) { + WebView.setDataDirectorySuffix(processName) + } + } + } + + override fun widget(): Int { + return AppInitializerPriority.SERIES_2.priority + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt new file mode 100644 index 0000000..a77a672 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/ThirdInitializer.kt @@ -0,0 +1,153 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import android.content.Context +import android.net.http.HttpResponseCache +import android.os.Environment +import android.text.TextUtils +import com.chad.library.adapter.base.module.LoadMoreModuleConfig +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.app.delegate.ToolbarViewDelegate +import com.remax.visualnovel.app.initializer.AppInitializers +import com.remax.visualnovel.app.widget.CustomLoadMoreView +import com.remax.visualnovel.app.widget.LoadMoreFooter +import com.remax.visualnovel.app.widget.RefreshHeader +import com.remax.visualnovel.utils.NotLoggingTree +import com.dylanc.loadingstateview.LoadingStateView +import com.github.boybeak.skbglobal.SoftKeyboardGlobal +import com.github.sahasbhop.apngview.ApngImageLoader +import com.lzf.easyfloat.EasyFloat +import com.pengxr.modular.eventbus.facade.launcher.IEventListener +import com.pengxr.modular.eventbus.facade.launcher.ModularEventBus +import com.pengxr.modular.eventbus.facade.template.BaseEvent +import com.scwang.smart.refresh.layout.SmartRefreshLayout +import timber.log.Timber +import java.io.File +import java.io.IOException + +/** + * Created by HJW on 2023/5/11 + * 三方库相关初始化 + */ +class ThirdInitializer(val application: Application) : AppInitializers { + + override fun init() { + ApngImageLoader.getInstance().init(application) + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant(NotLoggingTree()) + } + SoftKeyboardGlobal.install(application, false) + + LoadingStateView.setViewDelegatePool { + register(ToolbarViewDelegate()) + } + /** + * SVGA http缓存 + */ + val cacheDir = File(application.cacheDir, "http") + HttpResponseCache.install(cacheDir, 1024 * 1024 * 200) + EasyFloat.init(application, BuildConfig.DEBUG) + + LoadMoreModuleConfig.defLoadMoreView = CustomLoadMoreView() + //设置全局默认配置(优先级最低,会被其他设置覆盖) + SmartRefreshLayout.setDefaultRefreshInitializer { _, layout -> //全局设置(优先级最低) + layout.setEnableLoadMore(false) + layout.setEnableAutoLoadMore(false) + layout.setEnableOverScrollDrag(true) + layout.setEnableOverScrollBounce(true) + layout.setEnableLoadMoreWhenContentNotFull(false) + layout.setEnableScrollContentWhenRefreshed(true) + layout.setHeaderMaxDragRate(3.0f) + } + SmartRefreshLayout.setDefaultRefreshHeaderCreator { context, layout -> //全局设置header + layout.setEnableHeaderTranslationContent(true) + RefreshHeader(context) + } + SmartRefreshLayout.setDefaultRefreshFooterCreator { context, layout -> //全局设置footer + layout.setEnableFooterTranslationContent(true) + LoadMoreFooter(context) + } + + ModularEventBus.debug(BuildConfig.DEBUG) + .throwNullEventException(false) + .setEventListener(object : IEventListener { + override fun onEventPost(eventName: String, event: BaseEvent, data: T?) { + Timber.d("ModularEventBus发送事件 eventName: $eventName, event=$event data=$data") + } + }) + + //NIMClient.initV2(application, getSDKOptions(application)) + initLocalNimString() + } + + /*private fun getSDKOptions(context: Context): SDKOptions { + val options = SDKOptions() + with(options) { + this.appKey = "2d6abc076f434fc52320c7118de5fead" + enableV2CloudConversation = true + //在线多端同步未读数 + sessionReadAck = true + // 采用异步加载SDK + asyncInitSDK = true + //禁止后台进程唤醒UI进程 + disableAwake = true + useXLog = true + enableBackOffReconnectStrategy = true + checkManifestConfig = BuildConfig.DEBUG + loginCustomTag = ClientType.Android.toString() + notifyStickTopSession = true + mixPushConfig = MixPushConfig().apply { + fcmCertificateName = "VisualNovel" + } + if (BuildConfig.DEBUG) { + sdkStorageRootPath = getAppCacheDir(context) + "/nim" + } + } + return options + }*/ + + /** + * 配置 APP 保存图片/语音/文件/log等数据的目录 + * 这里示例用SD卡的应用扩展存储目录 + */ + private fun getAppCacheDir(context: Context): String? { + var storageRootPath: String? = null + try { + // SD卡应用扩展存储区(APP卸载后,该目录下被清除,用户也可以在设置界面中手动清除),请根据APP对数据缓存的重要性及生命周期来决定是否采用此缓存目录. + // 该存储区在API 19以上不需要写权限,即可配置 + if (context.externalCacheDir != null) { + storageRootPath = context.externalCacheDir!!.canonicalPath + } + } catch (e: IOException) { + e.printStackTrace() + } + if (TextUtils.isEmpty(storageRootPath)) { + // SD卡应用公共存储区(APP卸载后,该目录不会被清除,下载安装APP后,缓存数据依然可以被加载。SDK默认使用此目录),该存储区域需要写权限! + storageRootPath = Environment.getExternalStorageDirectory().toString() + "/" + context.packageName + } + return storageRootPath + } + + private fun initLocalNimString() { + /*NimStrings.DEFAULT.apply { + status_bar_multi_messages_incoming = application.getString(R.string.status_bar_multi_messages_incoming) + status_bar_ticker_text = application.getString(R.string.status_bar_ticker_text) + status_bar_image_message = application.getString(R.string.status_bar_image_message) + status_bar_audio_message = application.getString(R.string.status_bar_audio_message) + status_bar_video_message = application.getString(R.string.status_bar_video_message) + status_bar_file_message = application.getString(R.string.status_bar_file_message) + status_bar_location_message = application.getString(R.string.status_bar_location_message) + status_bar_notification_message = application.getString(R.string.status_bar_notification_message) + status_bar_avchat_message = application.getString(R.string.status_bar_avchat_message) + status_bar_tip_message = application.getString(R.string.status_bar_tip_message) + status_bar_custom_message = application.getString(R.string.status_bar_custom_message) + status_bar_unsupported_message = application.getString(R.string.status_bar_unsupported_message) + status_bar_hidden_message_content = application.getString(R.string.status_bar_hidden_message_content) + status_bar_hidden_message_title = application.getString(R.string.status_bar_hidden_message_title) + }*/ + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt new file mode 100644 index 0000000..a441a4a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/impl/UserInitializer.kt @@ -0,0 +1,20 @@ +package com.remax.visualnovel.app.initializer.impl + +import android.app.Application +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.app.initializer.AppInitializers + +/** + * Created by HJW on 2023/5/11 + * user相关初始化 + */ +class UserInitializer(val application: Application) : AppInitializers { + + /** + * 初始化必须放在本地数据存储框架初始化之后才行 + */ + override fun init() { + LoginManager.init() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt new file mode 100644 index 0000000..4053cb3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/initializer/utils/FirebaseHelper.kt @@ -0,0 +1,29 @@ +package com.remax.visualnovel.app.initializer.utils + +import com.google.android.gms.tasks.Task +import com.google.firebase.messaging.FirebaseMessaging +import timber.log.Timber + +/** + * Created by HJW on 2022/10/18 + */ +object FirebaseHelper { + + fun getToken(tokenCallback: ((String?) -> Unit)? = null) { + FirebaseMessaging.getInstance().token.addOnCompleteListener { task: Task -> + try { + Timber.d("firebase CompleteListener isSuccessful : %s", task.isSuccessful) + if (task.isSuccessful && task.result != null) { + val token = task.result + Timber.d("firebase token : %s", token) + tokenCallback?.invoke(token) + } else { + tokenCallback?.invoke(null) + } + } catch (e: Exception) { + Timber.d("firebase token exception: %s", e.localizedMessage) + tokenCallback?.invoke(null) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt new file mode 100644 index 0000000..caea749 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppGlobalViewModel.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.repository.api.MessageRepository +import com.remax.visualnovel.repository.api.UserRepository + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Created by HJW on 2022/11/1 + */ +@HiltViewModel +class AppGlobalViewModel @Inject constructor( + application: Application, + private val userRepository: UserRepository, + private val messageRepository: MessageRepository +) : AndroidViewModel(application) { + + + + /** + * 更新firebase设备码到后端,推送用 + */ + fun updateTerminal(terminalCode: String?) { + if (LoginManager.isLogin) { + viewModelScope.launch { +// userRepository.updateTerminal(terminalCode) + } + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt new file mode 100644 index 0000000..4ce3df3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/AppIMViewModel.kt @@ -0,0 +1,32 @@ +package com.remax.visualnovel.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.gson.Gson +import com.remax.visualnovel.utils.TimeUtils + +/*import com.netease.nimlib.sdk.NIMClient +import com.netease.nimlib.sdk.Observer +import com.netease.nimlib.sdk.StatusCode +import com.netease.nimlib.sdk.msg.MsgServiceObserve +import com.netease.nimlib.sdk.msg.constant.MsgTypeEnum +import com.netease.nimlib.sdk.msg.model.IMMessage*/ +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.abs + +/** + * Created by HJW on 2022/10/18 + * [Application]生命周期内的[AndroidViewModel] + */ +@HiltViewModel +class AppIMViewModel @Inject constructor(application: Application) : AndroidViewModel(application), + DefaultLifecycleObserver { +} + + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt new file mode 100644 index 0000000..3aa6722 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/AppViewModel.kt @@ -0,0 +1,16 @@ +package com.remax.visualnovel.app.viewmodel.base + +import com.remax.visualnovel.app.viewmodel.base.UserViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 应用相关viewmodel的父类 + */ +@HiltViewModel +open class AppViewModel @Inject constructor() : UserViewModel() { + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt new file mode 100644 index 0000000..0b438ed --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/BaseViewModel.kt @@ -0,0 +1,16 @@ +package com.remax.visualnovel.app.viewmodel.base + +import androidx.lifecycle.ViewModel + +/** + * Created by HJW on 2022/10/27 + */ +open class BaseViewModel : ViewModel() { + + open fun onStart() { + } + + open fun onDestroy() { + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt new file mode 100644 index 0000000..cf9ab9c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/PayViewModel.kt @@ -0,0 +1,28 @@ +package com.remax.visualnovel.app.viewmodel.base + +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 钱包。支付相关父类 + */ +@HiltViewModel +open class PayViewModel @Inject constructor() : BaseViewModel() { + +// @Inject +// lateinit var walletRepository: WalletRepository +// +// @Inject +// lateinit var payRepository: PayRepository +// +// private val _walletFlow = MutableSharedFlow>() +// val walletFlow = _walletFlow.asSharedFlow() +// +// suspend fun getMyWallet(): Response { +// return walletRepository.getMyWallet().apply { _walletFlow.emit(this) } +// } +// +// suspend fun checkOut(tradeNo: String) = payRepository.checkOut(tradeNo) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt new file mode 100644 index 0000000..4fae626 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/viewmodel/base/UserViewModel.kt @@ -0,0 +1,72 @@ +package com.remax.visualnovel.app.viewmodel.base + + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.entity.response.base.ApiFailedResponse +import com.remax.visualnovel.entity.response.base.ApiSuccessResponse +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.LoginRepository +import com.remax.visualnovel.repository.api.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * User相关viewmodel的父类 + */ +@HiltViewModel +open class UserViewModel @Inject constructor() : BaseViewModel() { + + @Inject + lateinit var userRepository: UserRepository + + private val _userInfoFlow = MutableStateFlow>(Response()) + val userInfoFlow: StateFlow> = _userInfoFlow.asStateFlow() + + suspend fun getMyBaseInfo(): Response { + return userRepository.getMyBaseInfo().also { _userInfoFlow.value = it } + } + + /** + * 公共返回,很多接口操作后需要再次请求用户数据 + */ + protected suspend fun returnUserResponse(response: Response<*>): Response { + return if (response.isApiSuccess) { + getMyBaseInfo() + } else { + ApiFailedResponse(response.errorCode, response.errorMsg) + } + } + + //suspend fun getNimInfo() = userRepository.getNimInfo() + + @Inject + lateinit var loginRepository: LoginRepository + + /** + * 公共返回,检查nickname是否存在 + */ + suspend fun checkNickname( + nickName: String?, + exUserId: String? = null, + apiCall: (suspend () -> Response)? = null + ): Response { + val checkRes = loginRepository.checkUserNickname(nickName, exUserId) + return if (checkRes.isApiSuccess) { + if (checkRes.data == true) { + ApiFailedResponse("", CommonApplicationProxy.application.getString(R.string.nickname_exist_error)) + } else { + apiCall?.invoke() ?: ApiSuccessResponse() + } + } else { + ApiFailedResponse(checkRes.errorCode, checkRes.errorMsg) + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt new file mode 100644 index 0000000..daeebd0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/CustomLoadMoreView.kt @@ -0,0 +1,40 @@ +package com.remax.visualnovel.app.widget + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.chad.library.adapter.base.loadmore.BaseLoadMoreView +import com.chad.library.adapter.base.viewholder.BaseViewHolder +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.setSize + +/** + * Created by HJW on 2020/9/18 + */ +class CustomLoadMoreView(private val showLoading: Boolean = true) : BaseLoadMoreView() { + + override fun getLoadComplete(holder: BaseViewHolder): View { + return holder.getView(R.id.load_more_load_complete_view) + } + + override fun getLoadEndView(holder: BaseViewHolder): View { + return holder.getView(R.id.load_more_load_end_view) + } + + override fun getLoadFailView(holder: BaseViewHolder): View { + return holder.getView(R.id.load_more_load_fail_view) + } + + override fun getLoadingView(holder: BaseViewHolder): View { + val loadingView = holder.getView(R.id.load_more_loading_view) + if (!showLoading) { + loadingView.setSize(height = 0) + } + return loadingView + } + + override fun getRootView(parent: ViewGroup): View { + // 整个 LoadMore 布局 + return LayoutInflater.from(parent.context).inflate(R.layout.view_load_more_common, parent, false) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt new file mode 100644 index 0000000..25f544a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/EmptyView.kt @@ -0,0 +1,113 @@ +package com.remax.visualnovel.app.widget + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.chad.library.adapter.base.BaseQuickAdapter +import com.drake.brv.PageRefreshLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.configs.NovelApplication +import com.remax.visualnovel.databinding.LayoutEmptyBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.utils.spannablex.utils.dp + + +fun PageRefreshLayout.setEmptyText( + @StringRes emptyTextResId: Int = 0, + @DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, + topMargin: Int? = null, +) { + emptyLayout = R.layout.layout_empty + stateEnabled = true + onEmpty { + LayoutEmptyBinding.bind(this).apply { + if (emptyIcon != 0) { + ivEmpty.setImageResource(emptyIcon) + ivEmpty.isVisible = true + } + if (emptyTextResId != 0) { + tvEmpty.setText(emptyTextResId) + tvEmpty.isVisible = true + } + topMargin?.let { + root.run { + setPaddingRelative(paddingStart, topMargin.dp, paddingEnd, paddingBottom) + } + } + } + } +} + +fun BaseQuickAdapter<*, *>.setMyEmptyView( + @StringRes emptyTextResId: Int = 0, + @DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, + topMargin: Int? = null, + @StringRes btnText: Int = 0, + btnInvoke: (() -> Unit)? = null +) { + NovelApplication.getCurrentActivity()?.let { activity -> + setEmptyView( + EmptyView(activity).createEmptyView(emptyTextResId, emptyIcon, topMargin, btnText, btnInvoke) + ) + } +} + +class EmptyView(private val context: Context) { + + private val viewBind: LayoutEmptyBinding = LayoutEmptyBinding.inflate(LayoutInflater.from(context)) + + fun createEmptyView( + @StringRes emptyTextResId: Int = 0, + @DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, + topMargin: Int? = null, + @StringRes btnText: Int = 0, + btnInvoke: (() -> Unit)? = null + ): View { + setEmptyView(emptyIcon, emptyTextResId) + topMargin?.let { + setMarginTop(it.dp) + } + if (btnText != 0) { + setBtn(btnText, btnInvoke) + } + return viewBind.root + } + + private fun setEmptyView(@DrawableRes emptyIcon: Int = R.mipmap.icon_new_empty, @StringRes emptyTextResId: Int = 0): EmptyView { + with(viewBind) { + ivEmpty.isVisible = false + emptyIcon.takeIf { it != 0 }?.let { + ivEmpty.setImageResource(it) + ivEmpty.isVisible = true + } + tvEmpty.isVisible = false + emptyTextResId.takeIf { it != 0 }?.let { + tvEmpty.text = context.getString(emptyTextResId) + tvEmpty.isVisible = true + } + } + return this + } + + private fun setBtn(@StringRes btnText: Int, btnInvoke: (() -> Unit)? = null): EmptyView { + with(viewBind.btnEmptyMessage) { + isVisible = true + text = context.getString(btnText) + setOnClick(this) { + btnInvoke?.invoke() + } + } + return this + } + + private fun setMarginTop(topMargin: Int): EmptyView { + viewBind.root.run { + setPaddingRelative(paddingStart, topMargin, paddingEnd, paddingBottom) + } + return this + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java new file mode 100644 index 0000000..e3a657c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadMoreFooter.java @@ -0,0 +1,92 @@ +package com.remax.visualnovel.app.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.remax.visualnovel.R; +import com.scwang.smart.refresh.layout.api.RefreshKernel; +import com.scwang.smart.refresh.layout.api.RefreshLayout; +import com.scwang.smart.refresh.layout.constant.RefreshState; +import com.scwang.smart.refresh.layout.constant.SpinnerStyle; +import com.scwang.smart.refresh.layout.util.SmartUtil; + +public class LoadMoreFooter extends LinearLayout implements com.scwang.smart.refresh.layout.api.RefreshFooter { + + public LoadMoreFooter(Context context) { + this(context, null); + } + + public LoadMoreFooter(Context context, @Nullable AttributeSet attrs) { + super(context, attrs, 0); + View.inflate(context, R.layout.load_more_loading_view, this); + setMinimumHeight(SmartUtil.dp2px(50)); + } + + @Override + public boolean setNoMoreData(boolean noMoreData) { + this.setVisibility(noMoreData ? View.GONE : View.VISIBLE); + return true; + } + + @NonNull + @Override + public View getView() { + return this; + } + + @NonNull + @Override + public SpinnerStyle getSpinnerStyle() { + return SpinnerStyle.Translate; + } + + @Override + public void setPrimaryColors(int... colors) { + + } + + @Override + public void onInitialized(@NonNull RefreshKernel kernel, int height, int maxDragHeight) { + + } + + @Override + public void onMoving(boolean isDragging, float percent, int offset, int height, int maxDragHeight) { + + } + + @Override + public void onReleased(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + + } + + @Override + public void onStartAnimator(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + + } + + @Override + public int onFinish(@NonNull RefreshLayout refreshLayout, boolean success) { + return 0; + } + + @Override + public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) { + + } + + @Override + public boolean isSupportHorizontalDrag() { + return false; + } + + @Override + public void onStateChanged(@NonNull RefreshLayout refreshLayout, @NonNull RefreshState oldState, @NonNull RefreshState newState) { + + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt new file mode 100644 index 0000000..0f16814 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/LoadingDialog.kt @@ -0,0 +1,40 @@ +package com.remax.visualnovel.app.widget + +import android.content.Context +import com.remax.visualnovel.databinding.DialogLoadingBinding +import com.remax.visualnovel.widget.dialoglib.LBindingDialog + +class LoadingDialog { + + private lateinit var dialog: LBindingDialog + private var context: Context? = null + + fun build(context: Context): LoadingDialog { + this.context = context + dialog = LBindingDialog(context,DialogLoadingBinding::inflate) + dialog.with() + .setCenter() + .setBgRadius(4) + .setWidth(80) + .setHeight(80) + dialog.setCancelable(false) + dialog.setCanceledOnTouchOutside(false) + return this + } + + fun getDialog(): LBindingDialog { + return dialog + } + + fun show() { + if (this::dialog.isInitialized) { + dialog.show() + } + } + + fun dismiss() { + if (this::dialog.isInitialized) { + dialog.dismiss() + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java new file mode 100644 index 0000000..6f3f810 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/RefreshHeader.java @@ -0,0 +1,109 @@ +package com.remax.visualnovel.app.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieAnimationView; +import com.remax.visualnovel.R; +import com.scwang.smart.refresh.layout.api.RefreshKernel; +import com.scwang.smart.refresh.layout.api.RefreshLayout; +import com.scwang.smart.refresh.layout.constant.RefreshState; +import com.scwang.smart.refresh.layout.constant.SpinnerStyle; +import com.scwang.smart.refresh.layout.util.SmartUtil; + +public class RefreshHeader extends LinearLayout implements com.scwang.smart.refresh.layout.api.RefreshHeader { + private final LottieAnimationView mProgressView;//刷新动画视图 + + public RefreshHeader(Context context) { + this(context, null); + } + + public RefreshHeader(Context context, @Nullable AttributeSet attrs) { + super(context, attrs, 0); + setGravity(Gravity.CENTER); + mProgressView = new LottieAnimationView(context); + mProgressView.setAnimation(R.raw.single_ring); + mProgressView.setSafeMode(true); + mProgressView.setRepeatCount(-1); + addView(mProgressView, SmartUtil.dp2px(26), SmartUtil.dp2px(26)); + setMinimumHeight(SmartUtil.dp2px(30)); + } + + @NonNull + @Override + public View getView() { + return this; + } + + @NonNull + @Override + public SpinnerStyle getSpinnerStyle() { + return SpinnerStyle.Translate; + } + + @Override + public void setPrimaryColors(int... colors) { + + } + + @Override + public void onInitialized(@NonNull RefreshKernel kernel, int height, int maxDragHeight) { + + } + + @Override + public void onMoving(boolean isDragging, float percent, int offset, int height, int maxDragHeight) { + if (isDragging){ + float currPer = percent / 3.0f; + if (currPer > 1.0f) currPer = 1.0f; + float frameF = mProgressView.getMaxFrame() * currPer; + int frame = (int) (frameF); + mProgressView.setFrame(frame); + mProgressView.invalidate(); + } + } + + @Override + public void onReleased(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + mProgressView.setRepeatCount(-1); + mProgressView.setProgress(0f); + mProgressView.playAnimation(); + } + + @Override + public void onStartAnimator(@NonNull RefreshLayout refreshLayout, int height, int maxDragHeight) { + + } + + @Override + public int onFinish(@NonNull RefreshLayout refreshLayout, boolean success) { + mProgressView.postDelayed(new Runnable() { + @Override + public void run() { + mProgressView.cancelAnimation(); + } + },1100); + return 1000; + } + + @Override + public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) { + + } + + @Override + public boolean isSupportHorizontalDrag() { + return false; + } + + @Override + public void onStateChanged(@NonNull RefreshLayout refreshLayout, @NonNull RefreshState oldState, @NonNull RefreshState newState) { + + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt new file mode 100644 index 0000000..a3e633c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsFeedbackWindow.kt @@ -0,0 +1,147 @@ +package com.remax.visualnovel.app.widget.tips + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.PopupWindow +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PopwindowFeedsBackTipsBinding +import com.remax.visualnovel.entity.request.AIFeedback +import com.remax.visualnovel.extension.setOnClick + +class TipsFeedbackWindow { + + private var popupWindow: PopupWindow? = null + + fun build( + context: Context, + optType: Int, + clickCallback: (Int, optType: Int) -> Unit + ): TipsFeedbackWindow { + val view = LayoutInflater.from(context).inflate(R.layout.popwindow_feeds_back_tips, null) + popupWindow = PopupWindow(context).apply { + isFocusable = true + contentView = view + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable()) + } + var currOptType = optType + + PopwindowFeedsBackTipsBinding.bind(view).run { + fun changeOptType() { + val likeColorToken = context.getString(R.string.color_primary_variant_normal) + val normalColorToken = context.getString(R.string.color_txt_primary_normal) + val iconSize = 20 + val iconPadding = 16 + + fun updateLikeDislikeIcons( + likeIconRes: String, + likeColor: String, + dislikeIconRes: String, + dislikeColor: String, + ) { + like.setIconFontDrawable( + likeIconRes, + iconColorToken = likeColor, + iconSize = iconSize, + iconPadding = iconPadding + ) + dislike.setIconFontDrawable( + dislikeIconRes, + iconColorToken = dislikeColor, + iconSize = iconSize, + iconPadding = iconPadding + ) + } + + when (currOptType) { + AIFeedback.LIKE -> updateLikeDislikeIcons( + context.getString(R.string.icon_post_recommend_fill), + likeColorToken, + context.getString(R.string.icon_post_notrecommend), + normalColorToken + ) + + AIFeedback.DISLIKE -> updateLikeDislikeIcons( + context.getString(R.string.icon_post_recommend), + normalColorToken, + context.getString(R.string.icon_post_notrecommend_fill), + likeColorToken + ) + + else -> updateLikeDislikeIcons( + context.getString(R.string.icon_post_recommend), + normalColorToken, + context.getString(R.string.icon_post_notrecommend), + normalColorToken + ) + } + + when (currOptType) { + AIFeedback.LIKE -> { + like.setIconFontDrawable( + context.getString(R.string.icon_post_recommend_fill), + iconColorToken = likeColorToken, + iconSize = iconSize, + iconPadding = iconPadding + ) + dislike.setIconFontDrawable( + context.getString(R.string.icon_post_notrecommend), + iconColorToken = normalColorToken, + iconSize = iconSize, + iconPadding = iconPadding + ) + } + } + } + + changeOptType() + setOnClick(copy, like, dislike) { + when (this) { + copy -> { + clickCallback.invoke(0, currOptType) + popupWindow?.dismiss() + } + + like -> { + currOptType = if (currOptType == AIFeedback.LIKE) AIFeedback.NONE else AIFeedback.LIKE + changeOptType() + clickCallback.invoke(1, currOptType) + } + + dislike -> { + currOptType = if (currOptType == AIFeedback.DISLIKE) AIFeedback.NONE else AIFeedback.DISLIKE + changeOptType() + clickCallback.invoke(2, currOptType) + } + } + } + } + return this + } + + fun showAsDropDown(view: View?) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view) + } + } + + fun showAsDropDown(view: View?, xoff: Int, yoff: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view, xoff, yoff) + } + } + + fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAtLocation(parent, gravity, x, y) + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt new file mode 100644 index 0000000..7a8e0b7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsMoreWindow.kt @@ -0,0 +1,92 @@ +package com.remax.visualnovel.app.widget.tips + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.PopupWindow +import androidx.annotation.StringRes +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.IconFontTextView +import com.remax.visualnovel.widget.uitoken.changeTextStyle + +class TipsMoreWindow { + data class TipsMoreUIData( + @StringRes val titleRes: Int, + @StringRes val iconRes: Int, + ) + + private var popupWindow: PopupWindow? = null + + fun build( + context: Context, + tipsData: List?, + clickCallback: (TipsMoreUIData) -> Unit = {} + ): TipsMoreWindow { + val view = LayoutInflater.from(context).inflate(R.layout.popwindow_btn_tips, null) + popupWindow = PopupWindow(context).apply { + isFocusable = true + contentView = view + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable()) + } + val group = view.findViewById(R.id.group) + group.removeAllViews() + tipsData?.forEach { item -> + val iconView = IconFontTextView(context) + group.addView(iconView) + iconView.apply { + gravity = Gravity.START + setSize(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + setPadding(8.dp, 12.dp, 8.dp, 12.dp) + changeTextStyle { + textUITextToken = context.getString(R.string.txt_label_l) + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + + } + setIconFontDrawable( + startIconFont = context.getString(item.iconRes), + iconColorToken = context.getString(R.string.color_txt_primary_normal), + iconSize = 20, + iconPadding = 16 + ) + setText(item.titleRes) + setOnClick(this) { + popupWindow?.dismiss() + clickCallback(item) + } + } + } + return this + } + + fun showAsDropDown(view: View?) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view) + } + } + + fun showAsDropDown(view: View?, xoff: Int, yoff: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view, xoff, yoff) + } + } + + fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAtLocation(parent, gravity, x, y) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java new file mode 100644 index 0000000..554b442 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsPopWindow.java @@ -0,0 +1,76 @@ +package com.remax.visualnovel.app.widget.tips; + +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.PopupWindow; +import android.widget.TextView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.extension.ViewExtKt; + + +public class TipsPopWindow { + + private PopupWindow popupWindow; + + public TipsPopWindow build(Context context, String content, int width) { + View view = LayoutInflater.from(context).inflate(R.layout.popwindow_tips, null); + popupWindow = new PopupWindow(context); + popupWindow.setFocusable(true); + popupWindow.setContentView(view); + if (width != ViewGroup.LayoutParams.MATCH_PARENT) { + ViewExtKt.setMargin(view, 0, 0, 0, 0); + TextView tv = view.findViewById(R.id.tvContent); + textWidth = tv.getPaint().measureText(content); + } + popupWindow.setWidth(width); + popupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + popupWindow.setOutsideTouchable(true); + popupWindow.setBackgroundDrawable(new ColorDrawable()); + + TextView tvContent = view.findViewById(R.id.tvContent); + tvContent.setText(content); + return this; + } + + private float textWidth; + + public float getTextWidth() { + return textWidth; + } + + public TipsPopWindow build(Context context, String content) { + return build(context, content, ViewGroup.LayoutParams.MATCH_PARENT); + } + + public TipsPopWindow build(Context context, int resId) { + return build(context, context.getString(resId)); + } + + public void showAsDropDown(View view) { + if (popupWindow.isShowing()) { + popupWindow.dismiss(); + } else { + popupWindow.showAsDropDown(view); + } + } + + public void showAsDropDown(View view, int xoff, int yoff) { + if (popupWindow.isShowing()) { + popupWindow.dismiss(); + } else { + popupWindow.showAsDropDown(view, xoff, yoff); + } + } + + public void showAtLocation(View parent, int gravity, int x, int y) { + if (popupWindow.isShowing()) { + popupWindow.dismiss(); + } else { + popupWindow.showAtLocation(parent, gravity, x, y); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt new file mode 100644 index 0000000..b3b475e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/app/widget/tips/TipsSwitchWindow.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.app.widget.tips + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.LayoutInflater +import android.view.View +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.StringRes +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.ui.SwitchView + +class TipsSwitchWindow { + private var popupWindow: PopupWindow? = null + + fun build( + context: Context, + @StringRes tips: Int, + isChecked: Boolean = false, + switchCallback: (SwitchView,Boolean) -> Unit + ): TipsSwitchWindow { + val view = LayoutInflater.from(context).inflate(R.layout.popwindow_switch_tips, null) + popupWindow = PopupWindow(context).apply { + isFocusable = true + contentView = view + isOutsideTouchable = true + setBackgroundDrawable(ColorDrawable()) + } + view.findViewById(R.id.tvContent).setText(tips) + view.findViewById(R.id.switchView).run { + this.isChecked = isChecked + setPressChanged { switchCallback(this,it) } + } + return this + } + + fun showAsDropDown(view: View?) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view) + } + } + + fun showAsDropDown(view: View?, xoff: Int, yoff: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAsDropDown(view, xoff, yoff) + } + } + + fun showAtLocation(parent: View?, gravity: Int, x: Int, y: Int) { + if (popupWindow!!.isShowing) { + popupWindow!!.dismiss() + } else { + popupWindow!!.showAtLocation(parent, gravity, x, y) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt new file mode 100644 index 0000000..972f396 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/configs/NovelApplication.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.configs + +import android.app.Activity +import android.content.res.Configuration +import androidx.multidex.MultiDex +import androidx.multidex.MultiDexApplication +import com.remax.visualnovel.app.base.app.ApplicationProxy +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.app.initializer.AppInitializersProvider +import com.remax.visualnovel.utils.StatusBarUtils +import com.tencent.mmkv.MMKV +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber +import java.lang.ref.WeakReference +import javax.inject.Inject + + +@HiltAndroidApp +class NovelApplication : MultiDexApplication() { + + companion object { + private var currentActivity: WeakReference? = null + + fun setCurrentActivity(activity: Activity?) { + currentActivity = if (activity == null) null else WeakReference(activity) + } + + fun getCurrentActivity(): Activity? { + return currentActivity?.get() + } + } + + private val proxies = listOf(CommonApplicationProxy) + + @Inject + lateinit var appInitializersProvider: AppInitializersProvider + + override fun onCreate() { + super.onCreate() + MultiDex.install(this) + proxies.forEach { it.onCreate(this) } + appInitializersProvider.startInit() + } + + override fun onTerminate() { + MMKV.onExit() + super.onTerminate() + proxies.forEach { + it.onTerminate() + } + } + + // 系统资源配置发生更改回调,例如主题模式,需要重新刷新多语言 + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + StatusBarUtils.resetNavBarHeight() + Timber.d("onConfigurationChanged ${newConfig.locales[0]}") + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt new file mode 100644 index 0000000..ffed149 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppConstant.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.constant + +/** + * Created by HJW on 2021/12/7 + */ +class AppConstant { + + companion object { + const val ANDROID = "android" + const val APP_CLIENT = "ANDROID" + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt new file mode 100644 index 0000000..d885c66 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/AppStatus.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.constant + +import com.remax.visualnovel.BuildConfig + + +class AppStatus { + + companion object { + + /** + * 是否是生产环境 + */ + val isProduct + get() = BuildConfig.FLAVOR == "product" + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt new file mode 100644 index 0000000..0c334aa --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/Gender.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.constant + +import androidx.annotation.StringRes +import com.remax.visualnovel.R + +/** + * 性别枚举 + */ +enum class Gender(val value: Int, @StringRes val txtRes: Int) { + MALE(0, R.string.male), + FEMALE(1, R.string.female), + NONCONFORMING(2, R.string.nonconforming), + OTHER(2, R.string.other), +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt new file mode 100644 index 0000000..5fb6e86 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/LockTypeConstant.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel.constant + +/** + * Created by HJW on 2023/8/25 + */ +class LockTypeConstant { + companion object { + const val PUBLIC = 1 + const val PRIVATE = 2 + + /** + * 图片公开 + */ + fun isOpen(pubType: Int?) = pubType != PRIVATE + + /** + * 图片是否解锁 + */ + fun isUnLock(lockType: String?) = lockType != LOCK + + const val LOCK = "LOCK" + const val UNLOCK = "UNLOCK" + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt new file mode 100644 index 0000000..511af44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/constant/StatusCode.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.constant + +/** + * Created by HJW on 2025/7/21 + */ +enum class StatusCode(val code:String) { + /** + * 登录过期 + */ + TOKEN_EXPIRED("10050001"), + + NO_ALBUM_PERMISSION("10010011"), + + UNUSED_PURCHASE_TOKEN("1019"), //无效的支付凭据 + + AI_USER_NOT_EXIST("10010012"), + + //余额不足 + INSUFFICIENT_BALANCE("INSUFFICIENT_BALANCE"), + + /** + * 前段自定义code + */ + UPLOAD_FILE_VIOLATION("112233") + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt new file mode 100644 index 0000000..79003ea --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomAlbumData.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel.entity.imbean.raw + +import com.remax.visualnovel.entity.model.base.BasePhoto + +/** + * Created by HJW on 2025/8/21 + */ + +data class CustomAlbumData( + var url: String, + var width: Int, + var height: Int, + var unlockPrice: Long?, + val albumId: Long? +) : BasePhoto() { + + val type = CustomRawData.IMAGE + + var messageServerId: String? = null + + override fun paramId(): Long { + return albumId ?: url.hashCode().toLong() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt new file mode 100644 index 0000000..f2f809c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomCallData.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.entity.imbean.raw + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil + +/** + * Created by HJW on 2025/8/21 + */ + +data class CustomCallData( + val type: String, + val callType: String, + // 通话总时长 + val duration: Long, +) { + companion object { + const val CALL_CANCEL = "CALL_CANCEL" + const val CALL_END = "CALL_END" + } + + val callTxt: String? + get() = when (callType) { + CALL_CANCEL -> { + CommonApplicationProxy.application.getString(R.string.call_canceled) + } + + CALL_END -> { + "${CommonApplicationProxy.application.getString(R.string.call_duration)} ${ + PDateUtil.formatTime( + CommonApplicationProxy.application, + duration + ) + }" + } + + else -> { + null + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt new file mode 100644 index 0000000..f8f688f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomGiftData.kt @@ -0,0 +1,12 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/23 + */ +data class CustomGiftData( + val giftId: Int, + val giftName: String, + val giftIcon: String, + val giftNum: Int, + val title: String, +) \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt new file mode 100644 index 0000000..cc1baf1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomLevelChangeData.kt @@ -0,0 +1,17 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/27 + */ +data class CustomLevelChangeData( + val type: String, + val title: String, + val heartbeatLevel: String, + val heartbeatLevelName: String, + val heartbeatLevelNum: Int, + val heartbeatVal: Double, +) { + val isLevelUp: Boolean + get() = type == CustomRawData.HEARTBEAT_LEVEL_UP +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt new file mode 100644 index 0000000..c29ac48 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomRawData.kt @@ -0,0 +1,36 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/21 + */ + +data class CustomRawData( + val type: String, + val url: String, + val width: Int, + val height: Int +) { + companion object { + // 发送图片 + const val IMAGE = "IMAGE" + + // IM发送礼物 + const val GIFT = "IM_SEND_GIFT" + + //心动等级升级 + const val HEARTBEAT_LEVEL_UP = "HEARTBEAT_LEVEL_UP" + + //心动等级降级 + const val HEARTBEAT_LEVEL_DOWN = "HEARTBEAT_LEVEL_DOWN" + + // IM通话结束 + const val CALL = "CALL" + + // IM通话中分数变化 + const val VOICE_CHAT_EMOTION_SCORE = "VOICE_CHAT_EMOTION_SCORE" + + //余额不足 关闭语音电话 + const val INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE" + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt new file mode 100644 index 0000000..b49394d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/imbean/raw/CustomScoreData.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.entity.imbean.raw + +/** + * Created by HJW on 2025/8/27 + */ +data class CustomScoreData( + val score: Double +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt new file mode 100644 index 0000000..a2a76ea --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/MyImgData.kt @@ -0,0 +1,19 @@ +package com.remax.visualnovel.entity.model + +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Photo + + +data class MyImgData( + val viewerId: Long, + val url: String?, + val subsampling: Boolean = false +) : Photo { + override fun id(): Long = viewerId + override fun itemType(): Int { + return when { + subsampling -> ItemType.SUBSAMPLING + else -> ItemType.PHOTO + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt new file mode 100644 index 0000000..93bf7cf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/model/base/BasePhoto.kt @@ -0,0 +1,21 @@ +package com.remax.visualnovel.entity.model.base + +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Photo + +/** + * Created by HJW on 2025/7/21 + */ +abstract class BasePhoto : Photo { + + abstract fun paramId(): Long + + override fun id(): Long { + return paramId() + } + + override fun itemType(): Int { + return ItemType.PHOTO + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt new file mode 100644 index 0000000..895e283 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/request/PlatformAccountVerifyDTO.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.entity.request + +import com.remax.visualnovel.constant.AppConstant +import com.remax.visualnovel.utils.AppUtils + +/** + * Created by HJW on 2023/10/19 + */ +data class PlatformAccountVerifyDTO( + /** + * 三方账号验证用 + */ + val thirdToken: String? = null, + val thirdType: String? = null, + val appClient: String = AppConstant.APP_CLIENT, + val deviceCode: String = AppUtils.getAndroidID(), + + /** + * common + */ + val authCode: String? = null, +) + +data class CompleteUserInfoInput( + var nickname: String? = null, + var sex: Int? = null, + var birthDay: Long? = null, + var headImage: String? = null, + var exUserId: String? = null, +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt new file mode 100644 index 0000000..0161a86 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/AppearanceImage.kt @@ -0,0 +1,32 @@ +package com.remax.visualnovel.entity.response + +import android.os.Parcelable +import com.remax.visualnovel.entity.model.base.BasePhoto +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2025/7/21 + */ + +@Parcelize +data class AppearanceImage( + var imageUrl: String? = null, + var imageWidth: Int? = null, + var imageHeight: Int? = null, + var status: String = PENDING, + var select: Boolean = false, + var isPlaying: Boolean = false, + var unlockPrice: Long = 0, + var tempId: Long = 0, +) : BasePhoto(), Parcelable { + + override fun paramId(): Long = tempId + + companion object { + const val NSFW = "NSFW" + const val COMPLETED = "COMPLETED" + const val FAILED = "FAILED" + const val PENDING = "PENDING" + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt new file mode 100644 index 0000000..30842b0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Book.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.entity.response + +/** + * Created by HJW on 2025/8/14 + */ +data class Book( + val aiId: String, + val birthday: Long, + val characterName: String, + val headImg: String +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt new file mode 100644 index 0000000..8aae888 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/Character.kt @@ -0,0 +1,96 @@ +package com.remax.visualnovel.entity.response + +import android.os.Parcelable +import com.remax.visualnovel.extension.calculateAge +import com.remax.visualnovel.extension.getNimAccountId +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2025/7/17 + */ +@Parcelize +data class Character( + var aiId: String? = null, + var userId: String? = null, + var sex: Int? = null, // 0,男;1,女;2,自定义 + var permission: Int? = null, //权限 1: 公开 2:私密 + var nickname: String? = null, + var idCard: String? = null, + var headImg: String? = null, + var birthday: Long? = null, + var showBirthday: Long? = null, + var roleCode: String? = null, + var roleName: String? = null, + var characterCode: String? = null, + var characterName: String? = null, + var tagCode: String? = null, + var tagName: String? = null, + var introduction: String? = null, // 人物简介 >= 10 字符 <= 300 字符 + var imageUrl: String? = null, + var homeImageUrl: String? = null, // 主页头图 + var imageWidth: Int? = null, + var imageHeight: Int? = null, + var aiUserExt: CharacterExt? = null, + var liked: Boolean? = null, //是否点过赞 + + var likedNum: Long? = null, + var chatNum: Long? = null, + var conversationNum: Long? = null, + var coinNum: Long? = null, + + //在IM中用 + val dialoguePrologue: String? = null, + val dialoguePitch: String? = null, + val dialogueSpeechRate: String? = null, + val voiceType: String? = null, + var backgroundImg: String? = null, + var isDefaultBackground: Boolean? = null, + var isMember: Boolean? = null, + var isHaveChatted: Boolean? = null, //是否聊过天 + var isDelChatted: Boolean? = null, //是否删除过会话 + var isAutoPlayVoice: Int? = null, //自动播放语音开关 1:开 0:关 + //var aiUserHeartbeatRelation: HeartbeatRelation? = null, + //var chatBubble: ChatBubble? = null, + + //排行榜使用 + var rankNo: Int? = null, + var heartbeatValTotal: Double? = null, + var giftCoinNum: Int? = null, + + //首页滑动卡片使用 + var isLimit: Boolean? = null, + var likedCount: Long? = null, + var heartbeatVal: Double? = null, + var character: String? = null, + var role: String? = null, + var tag: String? = null, + var isSecret: Boolean? = null, + //var albumList: List? = null, + + ) : Parcelable { + companion object { + const val UM_FREE = 1 + const val UM_PAID = 2 + } + + val age: Int + get() = (birthday ?: 0L).calculateAge() + + val nimAccountId: String + get() = aiId.getNimAccountId(true) +} + +@Parcelize +data class CharacterExt( + var profile: String? = null, //人物设定 >= 10 字符 <= 4000 字符 + var userProfile: String? = null, //人物设定 >= 10 字符 <= 4000 字符 + var dialogueStyle: String? = null, // 对话风格 >= 10 字符 <= 300 字符 + var userDialogueStyle: String? = null, // 对话风格 >= 10 字符 <= 300 字符 + var dialoguePrologue: String? = null, // 开场白 >= 10 字符 <= 150 字符 + var dialogueTimbreCode: String? = null, // 对话音色Code + var dialoguePitch: String? = null, // 对话-音高 + var dialogueSpeechRate: String? = null, // 对话-语速 + var imageStyleCode: String? = null, //形象风格code + var imageDesc: String? = null, //形象描述 >= 10 字符 <= 500 字符 + var imageReferenceUrl: String? = null, // 形象参考 +) : Parcelable \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt new file mode 100644 index 0000000..add362c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatBackground.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.entity.response + +import com.remax.visualnovel.entity.model.base.BasePhoto + +/** + * Created by HJW on 2025/8/18 + */ +data class ChatBackground( + val backgroundId: Int?, + val imgUrl: String, + var isDefault: Boolean, + var select: Boolean = false, + var isSelected: Boolean? = null, +) : BasePhoto() { + override fun paramId(): Long { + return imgUrl.hashCode().toLong() + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt new file mode 100644 index 0000000..9642172 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/PlatformAccountVerify.kt @@ -0,0 +1,22 @@ +package com.remax.visualnovel.entity.response + +/** + * Created by HJW on 2023/10/19 + */ +data class PlatformAccountVerify( + val authType: String, + val optType: String, + val authCode: String, + val token: String?, +) { + val isLogin + get() = !token.isNullOrBlank() + + companion object { + const val OPT_LOGIN = "L" + const val OPT_REGISTER = "R" + + const val AUTH_VC = "VC" + const val AUTH_PD = "PD" + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt new file mode 100644 index 0000000..4e86a81 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/User.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.entity.response + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2025/7/11 + */ + +@Parcelize +data class User( + val userId: String, + val idCard: String, + var birthday: Long?, + var nickname: String?, + var headImage: String?, + var sex: Int?, + val cpUserInfo: Boolean?, + val isMember: Boolean?, + val thirdEmail: String?, + val thirdNickname: String?, + val thirdType: String?, + // 可创建AI数量 + var canCreateAiCount: Int, + var createdAiCount: Int, +) : Parcelable + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt new file mode 100644 index 0000000..a56c5d6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/BaseVoice.kt @@ -0,0 +1,19 @@ +package com.remax.visualnovel.entity.response.base + +import kotlinx.parcelize.IgnoredOnParcel + +/** + * Created by HJW on 2025/7/21 + */ +open class BaseVoice { + + open fun id(): String = "" + open fun url(): String = "" + open fun filePathName(): String = "" + + @IgnoredOnParcel + var isPlaying: Boolean = false + + @IgnoredOnParcel + var isLoading: Boolean = false +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt new file mode 100644 index 0000000..28b728e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/base/Response.kt @@ -0,0 +1,92 @@ +package com.remax.visualnovel.entity.response.base + + +import com.google.gson.annotations.SerializedName +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.extension.toast + +/** + * Created by HJW on 2022/10/27 + */ +open class Response( + @SerializedName(value = "content") + val data: T? = null, + open val errorCode: String = "", + open val errorMsg: String = "", + val status: String = successCode +) { + + companion object { + const val successCode = "OK" + + /** + * zip打包的错误error封装 + * new + */ + inline fun createZipFailResponse(vararg data: Response<*>): ApiFailedResponse { + val failedResponse = ApiFailedResponse() + for (t in data) { + if (!t.isApiSuccess) { + failedResponse.errorCode = t.errorCode + failedResponse.errorMsg = t.errorMsg + break + } + } + return failedResponse + } + } + + val isOk: Boolean + get() = status == successCode + + val isApiSuccess: Boolean + get() = + this is ApiSuccessResponse || this is ApiEmptyResponse + + /** + * 将返回结果分为成功和失败2个高阶函数 + * + * 使用inline修饰,使2个参数可以调用外部函数return + */ + inline fun transformResult(apiSuccessCallback: ((T?) -> Unit) = {}, apiFailedCallback: ((Response) -> Unit) = {}): Response { + if (isApiSuccess) { + apiSuccessCallback.invoke(data) + } else { + apiFailedCallback.invoke(this) + } + return this + } +} + +inline fun Response.parseData(listenerBuilder: (ResultBuilder.() -> Unit), showToast: Boolean = false) { + val listener = ResultBuilder().also(listenerBuilder) + when (this) { + is ApiSuccessResponse -> listener.onSuccess(this.response) + is ApiEmptyResponse -> listener.onSuccess(null) + is ApiFailedResponse -> { + listener.onFailed(this.errorCode, this.errorMsg) + listener.onFailedWithData(this.data) + if (showToast) { + CommonApplicationProxy.application.toast(errorMsg) + } + } + } + listener.onComplete() +} + +class ResultBuilder { + var onSuccess: (data: T?) -> Unit = {} + var onFailed: (errorCode: String, errorMsg: String) -> Unit = { _, _ -> + + } + var onFailedWithData: (errorData: T?) -> Unit = {} + var onComplete: () -> Unit = {} +} + +data class ApiSuccessResponse(val response: T? = null) : Response(data = response) + +class ApiEmptyResponse : Response() + +data class ApiFailedResponse(override var errorCode: String = "", override var errorMsg: String = "", val errorData: T? = null) : + Response(data = errorData, errorCode = errorCode, errorMsg = errorMsg) + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt new file mode 100644 index 0000000..1b72773 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnLoginEvent.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.event.model + +/** + * Created by HJW on 2023/10/18 + */ +data class OnLoginEvent(val status: Int) { + + fun isLogin(): Boolean = status == LOGIN + + companion object { + const val LOGIN = 1 //登录成功 + const val LOGOUT = 2 //登出成功 + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt new file mode 100644 index 0000000..0a452c6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/OnPlayVoiceEvent.kt @@ -0,0 +1,9 @@ +package com.remax.visualnovel.event.model + +/** + * Created by HJW on 2023/10/25 + */ +data class OnPlayVoiceEvent( + val isStart: Boolean, + val id: String = "" +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt new file mode 100644 index 0000000..669e86f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/MainTab.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.event.model.tab + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Created by HJW on 2023/4/18 + */ +@Parcelize +enum class MainTab(val index: Int, val checkLogin: Boolean = true) : Parcelable { + + /** + * 主页的子fragment + */ + + TAB_BOOKS(0, false), + TAB_MANGAS(1, false), + TAB_ACTORS(2, false), + TAB_HISTORY(3, false), +} + +@Parcelize +enum class ContactTab(val index: Int) : Parcelable { + MESSAGE(0), FRIEND(1) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt new file mode 100644 index 0000000..759f02e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/model/tab/OnTabChangedEvent.kt @@ -0,0 +1,9 @@ +package com.remax.visualnovel.event.model.tab + +/** + * Created by HJW on 2023/10/23 + * + * jumpItem: 主页 tab切换 + */ +data class OnTabChangedEvent(val jumpItem: MainTab, val contactTab: ContactTab? = null) + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt new file mode 100644 index 0000000..e7f3c95 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UIEvents.kt @@ -0,0 +1,31 @@ +package com.remax.visualnovel.event.modular + +import com.remax.visualnovel.event.model.OnPlayVoiceEvent +import com.remax.visualnovel.event.model.tab.OnTabChangedEvent +import com.pengxr.modular.eventbus.facade.annotation.EventGroup + +/** + * Created by HJW on 2023/5/22 + * UI操作的事件 + */ +@EventGroup(moduleName = "UI", autoClear = true) +interface UIEvents { + + /** + * 首页底部tab 双击时回到顶部操作 + */ + fun mainScrollToTop() + + fun onNetworkConnect() + + /** + * 主页tab切换 + */ + fun onHomeTabChanged(): OnTabChangedEvent + + /** + * 播放语音开始/结束 + * @return Integer + */ + fun onSkillVoiceEvent(): OnPlayVoiceEvent +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt new file mode 100644 index 0000000..927fcd6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/event/modular/UserEvents.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.event.modular + +import com.remax.visualnovel.event.model.OnLoginEvent +import com.pengxr.modular.eventbus.facade.annotation.EventGroup + +/** + * Created by HJW on 2023/5/18 + * 当前用户相关的事件 + */ +@EventGroup(moduleName = "user", autoClear = true) +interface UserEvents { + + /** + * 登录状态变更 + */ + fun onLoginStatusChanged(): OnLoginEvent + + /** + * 个人信息更新 + */ + fun onPersonalInfoChanged() + + /** + * vip订阅成功 + */ + fun onVipSubscribed() + + /** + * 用户信息发生改变 + */ + fun onUserInfoChanged() + + /** + * 用户未读消息数改变 + */ + fun onUserUnReadChanged() +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt new file mode 100644 index 0000000..eb759d8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppBarLayoutExt.kt @@ -0,0 +1,106 @@ +package com.remax.visualnovel.extension + +import android.view.View +import androidx.core.widget.NestedScrollView +import com.remax.visualnovel.app.delegate.navBgAlpha +import com.remax.visualnovel.app.delegate.titleTextAlpha +import com.remax.visualnovel.utils.KeyboardUtils +import com.google.android.material.appbar.AppBarLayout +import com.remax.visualnovel.configs.NovelApplication + +/** + * Created by HJW on 2023/2/13 + */ + +/** + * 当布局中有AppBarLayout时,滑动渐隐公共toolbar的标题 + * @param titleView 滑动到title。渐变完成 + * @param titleGroup 当页面撑满整个状态栏时,给toolbarLayout最小高度,当toolbarLayout中的元素比较多时,渐变只到titleTextView的底部,才传这个进来 + * @param includeBgAlpha 是否渐隐toolbar的背景色 + * @param isFromToolbar toolbar是否在页面上方,默认toolbar和contentLayout重叠在一起,即是false + * + */ +fun AppBarLayout.addScrollerAlpha( + titleView: View, + titleGroup: View? = null, + includeBgAlpha: Boolean = false, + isFromToolbar: Boolean = false, + textAlpha: Boolean = true, + alphaChanged: ((alpha: Float, verticalOffset: Int) -> Unit)? = null +) { + context.findBaseActivity()?.apply { + if (includeBgAlpha) { + // 当主页面填满整个屏幕时,给一个最小高度,和标题栏的高度一样,滑动的时候显示即正常 + (titleGroup ?: titleView).minimumHeight = getNavHeight() + } + addOnOffsetChangedListener { _, verticalOffset -> + updateToolbar { + val alpha = if (isFromToolbar) + calculateScrollerAlphaFromToolbar(verticalOffset, titleView.bottom) + else + calculateScrollerAlpha(verticalOffset, titleView.bottom) + + alphaChanged?.invoke(alpha, verticalOffset) + if (textAlpha) { + titleTextAlpha = alpha + } + if (includeBgAlpha) { + navBgAlpha = alpha + } + } + } + } +} + +/** + * 同上,这是NestedScrollView的渐隐扩展方法 + * */ +fun NestedScrollView.addScrollerAlpha( + titleView: View, + includeBgAlpha: Boolean = false, + isFromToolbar: Boolean = false, + textAlpha: Boolean = true, + scrollChange: ((alpha: Float, scrollY: Int) -> Unit)? = null +) { + context.findBaseActivity()?.apply { + if (textAlpha) { + updateToolbar { + titleTextAlpha = 0f + } + } + setOnScrollChangeListener { _, _, scrollY, _, _ -> + if (KeyboardUtils.isSoftInputVisible(this)) { + KeyboardUtils.hideSoftInput(this) + } + val alpha = if (isFromToolbar) calculateScrollerAlphaFromToolbar( + scrollY, + titleView.bottom + ) else calculateScrollerAlpha( + scrollY, + titleView.bottom + ) + scrollChange?.invoke(alpha, scrollY) + updateToolbar { + if (textAlpha) { + titleTextAlpha = alpha + } + if (includeBgAlpha) { + navBgAlpha = alpha + } + } + } + } +} + +/** + * 滑动时关闭键盘 + */ +fun NestedScrollView.scrollAndHideKeyboard() { + setOnScrollChangeListener { _, _, _, _, _ -> + NovelApplication.getCurrentActivity()?.let { act -> + if (KeyboardUtils.isSoftInputVisible(act)) { + KeyboardUtils.hideSoftInput(act) + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt new file mode 100644 index 0000000..7795ee2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/AppCompatActivityExt.kt @@ -0,0 +1,52 @@ +package com.remax.visualnovel.extension + + +import android.app.Activity +import android.content.Intent + + + +///** +// * 退出编辑二次确认弹窗 +// */ +//fun Activity.showExitHintDialog(isFinish: Boolean = true, continueCallback: (() -> Unit)? = null) { +// showDoubleBtnDialog( +// getString(R.string.tips), +// getString(R.string.im_auto_input_back), +// rightBtnText = getString(R.string.continue_hint), +// rightBtnClick = { +// if (KeyboardUtils.isSoftInputVisible(this)) { +// KeyboardUtils.hideSoftInput(this) +// } +// continueCallback?.invoke() +// if (isFinish) { +// finish() +// } +// }) +//} + + +///** +// * 展示余额不足弹窗 +// */ +//fun AppCompatActivity.activityShowChargeDialog() { +// showDoubleBtnDialog( +// getString(R.string.tips), +// getString(R.string.balance_insufficient_hint), +// rightBtnText = getString(R.string.recharge), +// rightBtnClick = { +// ChargeActivity.open() +// }) +//} + + +/** + * 设置activity返回参数 + */ +inline fun Activity.setResultAndFinish(isFinish: Boolean = true, block: Intent.() -> Unit = {}) { + setResult(Activity.RESULT_OK, Intent().apply { + this.block() + }) + if (isFinish) finish() +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt new file mode 100644 index 0000000..9143837 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/BlurViewExt.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.extension + +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import eightbitlab.com.blurview.BlurView + +/** + * Created by HJW on 2025/8/8 + */ +fun BlurView.setup(root: ViewGroup, @ColorRes overlayColorRes: Int) { + setupWith(root) + setOverlayColor(ContextCompat.getColor(context, overlayColorRes)) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt new file mode 100644 index 0000000..2d7112a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ButtonExt.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.extension + +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + + +/** + * 切换点赞爱心状态 + */ +fun UITokenTextView.changeLikedStatus(isLike: Boolean) { + isVisible = true + changeTextColor { + textUIColorToken = + context.getString(if (isLike) R.string.color_important_normal else R.string.color_txt_primary_normal) + } + setText(if (isLike) R.string.icon_like_fill else R.string.icon_like) +} + +///** +// * 切换follow following 按钮状态 +// */ +//fun ButtonView.changeFollowStatus(followedStatus: String?) { +// when (followedStatus) { +// FollowedStatus.FOLLOWED -> { +// setText(R.string.following) +// setButtonStyle(ButtonView.DefaultButton_Tertiary) +// } +// +// FollowedStatus.CANCELED -> { +// setText(R.string.follow) +// setButtonStyle(ButtonView.DefaultButton_Primary) +// } +// +// else -> { +// setText(R.string.follow) +// setButtonStyle(ButtonView.DefaultButton_Primary) +// } +// } +//} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt new file mode 100644 index 0000000..39df9e9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ContextExt.kt @@ -0,0 +1,244 @@ +package com.remax.visualnovel.extension + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Typeface +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.scale +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.BaseBindingActivity +import com.remax.visualnovel.utils.StatusBarUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.manager.CustomToastManager +import dagger.hilt.android.internal.managers.FragmentComponentManager +import timber.log.Timber +import java.io.IOException + +/** + * context扩展方法 + */ + +/** + * 获取标题栏高度 + * navheight + 是否显示状态栏?:状态栏高度 or 0 + */ +fun Context.getNavHeight(isFullScreen: Boolean = true) = + resources.getDimensionPixelSize(R.dimen.nav_height) + if (isFullScreen) StatusBarUtils.statusBarHeight else 0 + + +/** + * 在fragment中使用,hilt注解库将fragment的context进行了包装 + * 外面需要判断强转 + */ +fun Context.findActivityContext(): Context { + return FragmentComponentManager.findActivity(this) +} + +fun Context.findBaseActivity(): BaseBindingActivity<*>? = findActivityContext() as? BaseBindingActivity<*> + +/** + * 快速设置状态栏颜色 + * color.background.specialmap + * color.background.district + * color.background.default + */ +fun Context.getBgColor(bgColorType: BgColorType = BgColorType.SPECIAL_MAP): Int { + return when (bgColorType) { + BgColorType.DEFAULT -> { + handleUIToken(R.string.color_background_default)?.color ?: 0 + } + + BgColorType.DISTRICT -> { + handleUIToken(R.string.color_background_district)?.color ?: 0 + } + + BgColorType.SPECIAL_MAP -> { + handleUIToken(R.string.color_background_specialmap)?.color ?: 0 + } + + BgColorType.TRANSPARENT -> { + handleUIToken(R.string.color_transparent)?.color ?: 0 + } + } +} + +fun Activity.setStatusBarColor(bgColorType: BgColorType = BgColorType.SPECIAL_MAP) { + val color = getBgColor(bgColorType) + StatusBarUtils.setColor(this, color) +} + +/** + * 返回iconFont的typeface + */ +private var iconFontTypeface: Typeface? = null +fun Context.getIconFontType(): Typeface { + if (iconFontTypeface == null) { + iconFontTypeface = Typeface.createFromAsset(assets, "iconfont/iconfont.ttf") + } + return iconFontTypeface!! +} + +private var D_Din_700: Typeface? = null +private var Bangers_400: Typeface? = null +private var Poppins_400: Typeface? = null +private var Poppins_500: Typeface? = null +private var Poppins_600: Typeface? = null +private var Poppins_700: Typeface? = null + +const val D_Din_700_typeface = "family/D-Din-700.ttf" +const val Bangers_400_typeface = "family/Bangers-400.ttf" +const val Poppins_400_typeface = "family/Poppins-400.ttf" +const val Poppins_500_typeface = "family/Poppins-500.ttf" +const val Poppins_600_typeface = "family/Poppins-600.ttf" +const val Poppins_700_typeface = "family/Poppins-700.ttf" + +/** + * 缓存字体包 + * @receiver Context + * @param typeface String? + * @return Typeface + */ +fun Context.getTextFontTypeface(typeface: String?): Typeface { + return when (typeface) { + Poppins_400_typeface -> { + if (Poppins_400 == null) { + Poppins_400 = Typeface.createFromAsset(assets, typeface) + } + Poppins_400 ?: Typeface.createFromAsset(assets, typeface) + } + + Poppins_500_typeface -> { + if (Poppins_500 == null) { + Poppins_500 = Typeface.createFromAsset(assets, typeface) + } + Poppins_500 ?: Typeface.createFromAsset(assets, typeface) + } + + Poppins_600_typeface -> { + if (Poppins_600 == null) { + Poppins_600 = Typeface.createFromAsset(assets, typeface) + } + Poppins_600 ?: Typeface.createFromAsset(assets, typeface) + } + + Poppins_700_typeface -> { + if (Poppins_700 == null) { + Poppins_700 = Typeface.createFromAsset(assets, typeface) + } + Poppins_700 ?: Typeface.createFromAsset(assets, typeface) + } + + D_Din_700_typeface -> { + if (D_Din_700 == null) { + D_Din_700 = Typeface.createFromAsset(assets, typeface) + } + D_Din_700 ?: Typeface.createFromAsset(assets, typeface) + } + + Bangers_400_typeface -> { + if (Bangers_400 == null) { + Bangers_400 = Typeface.createFromAsset(assets, typeface) + } + Bangers_400 ?: Typeface.createFromAsset(assets, typeface) + } + + else -> { + Typeface.createFromAsset(assets, typeface) + } + } +} + +fun Context.toast(msg: String?, isLong: Boolean = false) { + Handler(Looper.getMainLooper()).post { + CustomToastManager.showToast(this, msg, isLong) + } +} + +fun Context.toast(@StringRes msg: Int, isLong: Boolean = false) { + toast(getString(msg), isLong) +} + +/** + * 复制到剪切板 + */ +fun Context.copyLink(link: String?) { + if (link == null) return + val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val data = ClipData.newPlainText("Label", link) + cm.setPrimaryClip(data) + toast(getString(R.string.copy_successful)) +} + +@Throws(IOException::class) +fun Context.readJsonAsset(fileName: String): String { + val inputStream = assets.open(fileName) + val size = inputStream.available() + val buffer = ByteArray(size) + inputStream.read(buffer) + inputStream.close() + return String(buffer, Charsets.UTF_8) +} + + +/** + * 打开外部浏览器 + * alternateUrl:备用地址 外部应用不存在的情况下调至备用页面 + */ +fun Context.jumpBrowser(url: String?, alternateUrl: String? = null) { + runCatching { + var realUrl = url ?: "" + if (realUrl.isNotBlank()) { + if (!realUrl.startsWith("http://") && !realUrl.startsWith("https://")) { + realUrl = "http://$realUrl" + } + val uri = Uri.parse(realUrl) + Intent(Intent.ACTION_VIEW, uri).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(this) + } + } + }.onFailure { + alternateUrl?.let(::jumpBrowser) + } +} + +/** + * 调用系统分享 + */ +fun Context.jumpShare(aiId: String?) { + try { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + val url = "${BuildConfig.HOST}@$aiId" + intent.putExtra(Intent.EXTRA_TEXT, url) + startActivity(Intent.createChooser(intent, getString(R.string.share))) + } catch (e: Exception) { + Timber.e(e) + } +} + + +/** + * 创建一个大小固定的bitmap + */ +fun Context.createScaledBitmap(@DrawableRes drawableRes: Int, size: Int? = null): Bitmap = + if (size != null) { + (ContextCompat.getDrawable(this, drawableRes) as BitmapDrawable).bitmap.scale(size.dp, size.dp, false) + } else { + Bitmap.createBitmap((ContextCompat.getDrawable(this, drawableRes) as BitmapDrawable).bitmap) + } + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt new file mode 100644 index 0000000..a857d48 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/CoroutineExt.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.extension + +import kotlinx.coroutines.CancellableContinuation +import kotlin.coroutines.resume + +/** + * Created by HJW on 2023/7/14 + */ + +fun CancellableContinuation.resumeWithActive(value: T) { + if (isActive) { + resume(value) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt new file mode 100644 index 0000000..3b4dfa9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DialogExt.kt @@ -0,0 +1,291 @@ +package com.remax.visualnovel.extension + +import android.annotation.SuppressLint +import android.app.Activity +import android.text.method.ScrollingMovementMethod +import android.view.Gravity +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.DialogDoubleBtnBinding +import com.remax.visualnovel.databinding.DialogSingleBtnLayout2Binding +import com.remax.visualnovel.databinding.DialogSingleBtnLayoutBinding +import com.remax.visualnovel.databinding.DialogSingleBtnWithIconBinding +import com.remax.visualnovel.extension.glide.loadNoCenterCrop +import com.remax.visualnovel.utils.KeyboardUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.dialoglib.LBindingDialog +import com.remax.visualnovel.widget.dialoglib.ScreenUtils +import com.remax.visualnovel.widget.ui.buttons.ButtonView +import com.remax.visualnovel.widget.uitoken.changeTextStyle +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import kotlin.math.min + +/** + * Created by HJW on 2025/7/18 + */ + +/** + * 双按钮的全局统一的dialog样式 + */ +fun Activity.showDoubleBtnDialog( + title: String = getString(R.string.tips), + text: String, + topBtnText: String? = null, + topBtnClick: (() -> Unit)? = null, + bottomBtnText: String? = null, + bottomBtnClick: (() -> Unit)? = null, + isShow: Boolean = true, + isDel: Boolean = false, + contentGravity: Int = Gravity.CENTER, + needMaxHeight: Boolean = true, + @DrawableRes titleImage: Int? = null +): LBindingDialog { + val dialog = LBindingDialog(this, DialogDoubleBtnBinding::inflate) + .with() + .setCenter() + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + if (needMaxHeight) { + textTv.maxHeight = (ScreenUtils.getHeightRealPixels() * 0.7).toInt() + textTv.movementMethod = ScrollingMovementMethod.getInstance() + } + + setOnClick(leftBtn, rightBtn) { + when (this) { + leftBtn -> bottomBtnClick?.invoke() + rightBtn -> topBtnClick?.invoke() + } + dialog.dismiss() + } + + titleTv.text = title + titleIv.isVisible = titleImage != null + if (titleIv.isVisible) { + titleTv.isVisible = false + titleIv.setImageResource(titleImage!!) + textTv.setMargin(topMargin = 10.dp) + } + + textTv.gravity = contentGravity + textTv.text = "$text\n" + if (bottomBtnText != null) { + leftBtn.text = bottomBtnText + } + if (isDel){ + rightBtn.setButtonStyle(ButtonView.DefaultButton_Destructive) + } + if (topBtnText != null) { + rightBtn.text = topBtnText + } + } + + if (isShow) { + dialog.show() + } + + return dialog +} + +/** + * 单按钮的全局统一的dialog样式 + */ +fun Activity.showSingleBtnDialog( + title: String = getString(R.string.tips), + text: String, + btnText: String? = null, + contentGravity: Int = Gravity.CENTER, + isShow: Boolean = true, + autoDismiss: Boolean = true, + needMaxHeight: Boolean = true, + btnClick: (() -> Unit)? = null +): LBindingDialog { + val dialog = showSingleCancelBtnDialog(title, text, btnText, btnClick, isShow, autoDismiss, contentGravity, needMaxHeight) + dialog.binding.cancelBtn.isVisible = false + return dialog +} + + +/** + * 单按钮带图标的全局统一的dialog样式 + */ +fun Activity.showIconAndSingleBtnDialog( + title: String, + text: String, + iconRes: Int? = null, + btnText: String? = null, + btnClick: (() -> Unit)? = null, + isShow: Boolean = true, + closeIsShow: Boolean = false, + iconUrl: String? = null, + contentGravity: Int = Gravity.CENTER, + needMaxHeight: Boolean = true +): LBindingDialog { + val dialog = LBindingDialog(this, DialogSingleBtnWithIconBinding::inflate).with() + .setCenter() + .setCancelBtn(R.id.ivClose) + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + if (needMaxHeight) { + tvText.maxHeight = (ScreenUtils.getHeightRealPixels() * 0.7).toInt() + tvText.movementMethod = ScrollingMovementMethod.getInstance() + } + tvText.gravity = contentGravity + setOnClick(tvBtn) { + dialog.dismiss() + btnClick?.invoke() + } + + ivClose.isVisible = closeIsShow + if (iconRes != null) { + ivIcon.setImageResource(iconRes) + } else { + ivIcon.loadNoCenterCrop(iconUrl ?: "") + } + tvTitle.text = title + tvText.text = text + tvBtn.text = btnText ?: this@showIconAndSingleBtnDialog.getString(R.string.i_understand) + } + if (isShow) { + dialog.show() + } + return dialog +} + +fun Activity.showMoreTxtDialog( + titleText: String = getString(R.string.tips), + texts: List, + btnText: String? = null, + btnClick: (() -> Unit)? = null, + isShow: Boolean = true, + autoDismiss: Boolean = true, + showCloseBotton: Boolean = false, + contentGravity: Int = Gravity.START, + showCancel: Boolean = false +): LBindingDialog { + val dialog = LBindingDialog(this, DialogSingleBtnLayout2Binding::inflate).with() + .setCenter() + .setCancelBtn(R.id.cancelBtn) + .setCancelBtn(R.id.closeBtn) + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + cancelBtn.isVisible = showCancel + closeBtn.isVisible = showCloseBotton + + dialog.setOnShowListener { + val groupHeight = group.measuredHeight + val maxHeight = ScreenUtils.getHeightRealPixels() * 0.7f - 24.dp - 96.dp + scrollView.setSize(height = min(groupHeight, maxHeight.toInt())) + } + + setOnClick(okBtn) { + btnClick?.invoke() + if (autoDismiss) { + dialog.dismiss() + } + } + okBtn.setText(R.string.i_understand) + btnText?.let(okBtn::setText) + + title.text = titleText + + group.removeAllViews() + texts.forEachIndexed { index, s -> + val textView = UITokenTextView(this@showMoreTxtDialog) + group.addView(textView) + with(textView) { + setSize(ViewGroup.LayoutParams.MATCH_PARENT) + text = s + gravity = contentGravity + changeTextStyle { + textUIColorToken = getString(R.string.color_txt_primary_normal) + textUITextToken = getString(R.string.txt_body_m) + } + if (index > 0) { + setMargin(topMargin = 16.dp) + } + } + } + if (isShow) { + dialog.show() + } + } + + return dialog +} + +/** + * 单按钮的全局统一的dialog样式-待顶部关闭按钮 + */ +@SuppressLint("SetTextI18n") +fun Activity.showSingleCancelBtnDialog( + title: String, + text: String, + btnText: String? = null, + btnClick: (() -> Unit)? = null, + isShow: Boolean = true, + autoDismiss: Boolean = true, + contentGravity: Int = Gravity.CENTER, + needMaxHeight: Boolean = true +): LBindingDialog { + val dialog = LBindingDialog(this, DialogSingleBtnLayoutBinding::inflate) + .with() + .setCenter() + .setCancelBtn(R.id.cancelBtn) + + dialog.setCanceledOnTouchOutside(false) + dialog.setCancelable(false) + + dialog.binding.run { + if (needMaxHeight) { + singleText.maxHeight = (ScreenUtils.getHeightRealPixels() * 0.7).toInt() + singleText.movementMethod = ScrollingMovementMethod.getInstance() + } + + setOnClick(singleBtn) { + btnClick?.invoke() + if (autoDismiss) { + dialog.dismiss() + } + } + + singleTitle.text = title + singleText.text = "$text\n" + singleText.gravity = contentGravity + singleBtn.text = btnText ?: this@showSingleCancelBtnDialog.getString(R.string.i_understand) + } + if (isShow) { + dialog.show() + } + return dialog +} + + +/** + * 退出编辑二次确认弹窗 + */ +fun Activity.showExitHintDialog(isFinish: Boolean = true, continueCallback: (() -> Unit)? = null) { + showDoubleBtnDialog( + getString(R.string.tips), + getString(R.string.exit_dialog_hint), + topBtnText = getString(R.string.exit), + topBtnClick = { + if (KeyboardUtils.isSoftInputVisible(this)) { + KeyboardUtils.hideSoftInput(this) + } + continueCallback?.invoke() + if (isFinish) { + finish() + } + }) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt new file mode 100644 index 0000000..7839d71 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/DoubleExt.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.extension + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy + +/** + * Created by HJW on 2025/8/27 + */ + + +fun Double?.getTemperatureTxt() = "$this${CommonApplicationProxy.application.getString(R.string.temperature)}" \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt new file mode 100644 index 0000000..6f92465 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/EditTextExt.kt @@ -0,0 +1,67 @@ +package com.remax.visualnovel.extension + +import android.view.KeyEvent +import android.widget.EditText +import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.internal.ThreadUtil +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch + +/** + * 搜索框防抖 + * tips: flow流需要在协程中创建,使用当前EditText的activity.lifecycleOwner创建,搜索不走的话,需要另行创建协程 + */ +fun EditText.startSearch(resInvoke: (res: String) -> Unit) { + (context.findActivityContext() as? AppCompatActivity)?.run { + lifecycleScope.launch { + textChangeFlow() + .debounce(500) + .collect { + resInvoke.invoke(it) + } + } + } +} + +private fun EditText.textChangeFlow(): Flow { + return callbackFlow { + require(ThreadUtil.isMainThread()) + val listener = doOnTextChanged { text, _, _, _ -> trySend(text?.toString() ?: "") } + awaitClose { removeTextChangedListener(listener) } + } +} + +/** + * 屏蔽回车换行 + */ +fun EditText.filterEnter() { + this.setOnEditorActionListener { _, _, event -> + (event?.keyCode ?: 0) == KeyEvent.KEYCODE_ENTER + } +} + +/** + * 输入框不允许输入换行符 + */ +fun EditText.withoutNewLineChanges(resInvoke: (res: CharSequence) -> Unit) { + this.run { + filterEnter() + doAfterTextChanged { + if (this.isFocused) { + if (it?.contains("\n") == true) { + val content = it.toString().replace("\n", "") + this.setText(content) + this.setSelection(content.length) + } else { + resInvoke.invoke(it?.toString() ?: "") + } + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt new file mode 100644 index 0000000..658a2ae --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FileExt.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import android.os.Environment +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.util.Base64 + +/** + * Created by HJW on 2025/8/21 + */ +/** + * 将文件转换为 Base64 字符串 + * @return Base64 编码的字符串 + * @throws IOException 如果读取文件失败 + */ +fun File.toBase64(): String { + // 检查文件是否存在 + if (!this.exists() || !this.isFile) { + Timber.e("File.toBase64() File does not exist or is not a valid file") + return "" + } + + // 使用 FileInputStream 读取文件内容 + return FileInputStream(this).use { inputStream -> + // 创建缓冲区,优化大文件读取 + val buffer = ByteArray(8192) + val byteArrayOutputStream = java.io.ByteArrayOutputStream() + var bytesRead: Int + + // 流式读取文件 + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + byteArrayOutputStream.write(buffer, 0, bytesRead) + } + + // 转换为 Base64 字符串 + Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray()) + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt new file mode 100644 index 0000000..fd764c9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FloatExt.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import kotlin.math.abs + +/** + * Created by HJW on 2022/9/27 + */ + +//计算滑动alpha 页面从屏幕顶部开始 +internal fun Context.calculateScrollerAlpha(offset: Int, height: Int) = calculateScrollerAlphaFromToolbar(offset, getFullScrollerTopHeight(height)) + +//计算滑动alpha 页面从导航栏下方开始 +internal fun calculateScrollerAlphaFromToolbar(offset: Int, height: Int) = abs(offset.toFloat() / height.toFloat()) + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt new file mode 100644 index 0000000..7df7fcb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/FlowKtx.kt @@ -0,0 +1,144 @@ +package com.remax.visualnovel.extension + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.remax.visualnovel.app.AbsView +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.entity.response.base.ResultBuilder +import com.remax.visualnovel.entity.response.base.parseData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +inline fun launchFlow( + crossinline requestBlock: suspend () -> Response, + noinline startCallback: (() -> Unit)? = null, + noinline completeCallback: (() -> Unit)? = null, +): Flow> { + return flow { + emit(requestBlock()) + }.onStart { + startCallback?.invoke() + }.onCompletion { + completeCallback?.invoke() + }.flowOn(Dispatchers.Main) +} + +/** + * 简单的请求,不返回任何实体类, 无loading和toast,数据可通过livedata/flow监听 + */ +inline fun AbsView.launchWithRequest(crossinline requestBlock: suspend () -> Unit, showLoading: Boolean = false) { + lifecycleScope.launch { + flow { + emit(requestBlock()) + }.onStart { + if (showLoading) showLoading() + }.onCompletion { + if (showLoading) hideLoading() + }.collect() + } +} + +/** + * 调用上面的,默认loading + */ +inline fun AbsView.launchWithRequestLoading(crossinline requestBlock: suspend () -> Unit) { + launchWithRequest(requestBlock, true) +} + +/** + * 链式调用,返回结果的处理都在一起,viewmodel中不需要创建一个livedata对象 + * 适用于不需要监听数据变化/一次性使用的场景,比如提交表单/登录 + * 屏幕旋转,Activity销毁重建,数据会消失 + * + * 默认无toast,无loading + */ +inline fun AbsView.launchAndCollect( + crossinline requestBlock: suspend () -> Response, + showLoading: Boolean = false, + showToast: Boolean = true, + crossinline listenerBuilder: (ResultBuilder.() -> Unit) = {} +) { + lifecycleScope.launch { + launchFlow(requestBlock, { if (showLoading) showLoading() }) { if (showLoading) hideLoading() }.collect { response -> + response.parseData(listenerBuilder, showToast) + } + } +} + +inline fun AbsView.launchAndLoadingCollect( + crossinline requestBlock: suspend () -> Response, showToast: Boolean = true, crossinline listenerBuilder: (ResultBuilder.() -> Unit) = {} +) { + launchAndCollect(requestBlock, showLoading = true, showToast = showToast, listenerBuilder = listenerBuilder) +} + +/** + * 简单flow流订阅 生命周期安全 + */ +inline fun Flow.flowWithLaunch( + lifecycleOwner: LifecycleOwner, minActiveState: Lifecycle.State = Lifecycle.State.STARTED, crossinline resCallback: ((t: T?) -> Unit) +) { + lifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect { + resCallback.invoke(it) + } + } +} + + +fun Flow.flowWithLifecycle(lifecycle: Lifecycle, minActiveState: Lifecycle.State = Lifecycle.State.STARTED): Flow = callbackFlow { + lifecycle.repeatOnLifecycle(minActiveState) { + this@flowWithLifecycle.collect { + send(it) + } + } + close() +} + +/** + * response liveData监听 + */ +inline fun LiveData>.observeIn( + lifecycleOwner: LifecycleOwner, showToast: Boolean = true, crossinline listenerBuilder: ResultBuilder.() -> Unit +) { + this.observe(lifecycleOwner, Observer { + it.parseData(listenerBuilder, showToast) + }) +} + +/** + * 订阅UI上展示Flow数据流 + * + * 状态(State)用 StateFlow,粘性的 ;事件(Event)用 SharedFlow 在其 replayCache 中保留特定数量的最新值 + * MutableSharedFlow :一次性事件,不需要重放的状态变更(例如 Toast) + * MutableStateFlow : 页面需要的状态,比如UI的刷新,多次执行没有任何问题 + * collectLastValue = true时,stateFlow也不会发送未改变的value,就和sharedFlow一样的用法 + */ +inline fun Flow>.collectIn( + lifecycleOwner: LifecycleOwner, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + showToast: Boolean = true, + crossinline listenerBuilder: ResultBuilder.() -> Unit +): Job { + return lifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect { + it.parseData(listenerBuilder, showToast) + } + } +} + + + + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt new file mode 100644 index 0000000..c20ec3b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/GlobalExt.kt @@ -0,0 +1,145 @@ +package com.remax.visualnovel.extension + +import android.os.SystemClock +import android.view.View +import androidx.lifecycle.LifecycleCoroutineScope +import com.remax.visualnovel.R +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.Routers +import com.remax.visualnovel.utils.TimeUtils +import com.dylanc.loadingstateview.viewClickHandler +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * 批量添加点击事件,防抖500毫秒 + * */ +inline fun setOnClick(vararg v: View?, crossinline block: viewClickHandler) { + val interval = 500L + v.forEach { + it?.let { view -> + view.setOnClickListener { + val lastClickedTimestamp = view.getTag(R.id.view_last_click_timestamp)?.toString()?.toLongOrNull() ?: 0L + val currTimestamp = SystemClock.uptimeMillis() + if (currTimestamp - lastClickedTimestamp < interval) return@setOnClickListener + view.setTag(R.id.view_last_click_timestamp, currTimestamp) + view.block() + } + } + } +} + +/** + * 检查是否登录,未登录直接跳转登录页面 + * */ +fun checkLogin(then: (() -> Unit)? = null): Boolean { + return if (LoginManager.isLogin) { + then?.invoke() + true + } else { + Routers.navigation(Routers.LOGIN) + false + } +} + +//防止弹窗多次弹出 +private var addGamerIdHintDialogIsShow = false + +inline fun String?.convertFromJson(): T? { + return try { + Gson().fromJson(this ?: "", T::class.java) + } catch (_: Exception) { + null + } +} + +/** + * map 解析json + * */ +inline fun Map?.convertFromJson(): T? { + return try { + Gson().fromJson(Gson().toJson(this ?: HashMap()) ?: "", T::class.java) + } catch (_: Exception) { + null + } +} + +inline fun convertListFromJson(param: String?): List { + return try { + val itemType = object : TypeToken>() {}.type + Gson().fromJson(param, itemType) ?: emptyList() + } catch (e: Exception) { + emptyList() + } +} + +val buffDecimalFormat: DecimalFormat + get() { + return DecimalFormat("#,###,##0.00") + } + + +fun formatBuff(buff: Long?): String { + val obj = BigDecimal(buff ?: 0L).divide(BigDecimal(100), 2, RoundingMode.DOWN) + return buffDecimalFormat.format(obj.toDouble()) +} + +fun formatBuffWithIntegers(buff: Long?): String { + val buffInt = ((buff ?: 0L) / 100) + return formatNumber(buffInt) +} + +/** + * 价格输入框不能格式化价格 + */ +fun formatPrice(buff: Long?, isIntegers: Boolean = true) = if (isIntegers) + formatBuffWithIntegers(buff) +else + BigDecimal(buff ?: 0L).divide(BigDecimal(100)).setScale(2).toString() + +val numberDecimalFormat = DecimalFormat("#,###,###") +fun formatNumber(number: Long?): String { + val formatNumber = number ?: 0 + if (formatNumber < 1000) return formatNumber.toString() + return numberDecimalFormat.format(formatNumber) +} + +val birthDateFormatParse by lazy { SimpleDateFormat(TimeUtils.YMD_PATTERN, Locale.getDefault()) } + +/** + * 语音录音计时器 + * + * @param total + * @return job + */ +fun LifecycleCoroutineScope.countDownCoroutines(total: Int, onTick: (Int) -> Unit, onFinish: () -> Unit): Job { + return flow { + for (i in total downTo 1) { + emit(i) + delay(1000) + } + } + .flowOn(Dispatchers.Main) + .onCompletion { + onFinish.invoke() + } + .onEach { + onTick.invoke(it) + } + .launchIn(this) +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt new file mode 100644 index 0000000..5929cf4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ImageViewExt.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.extension + +import android.widget.ImageView +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.constant.Gender + +/** + * Created by HJW on 2025/7/16 + */ + +/** + * 显示性别 + */ +fun ImageView.showGender(gender: Int?) { + isVisible = true + when (gender) { + Gender.MALE.value -> setImageResource(R.mipmap.ic_gender_male) + + Gender.FEMALE.value -> setImageResource(R.mipmap.ic_gender_female) + + Gender.NONCONFORMING.value -> setImageResource(R.mipmap.ic_gender_nonconforming) + + Gender.OTHER.value -> setImageResource(R.mipmap.ic_gender_nonconforming) + + else -> { + isVisible = false + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt new file mode 100644 index 0000000..c089465 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/IntExt.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.StatusBarUtils + +/** + * Created by HJW on 2022/9/27 + */ +internal fun Context.getFullScrollerTopHeight(height: Int) = + height - StatusBarUtils.statusBarHeight - resources.getDimensionPixelSize(R.dimen.nav_height) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt new file mode 100644 index 0000000..6841585 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ListExt.kt @@ -0,0 +1,88 @@ +package com.remax.visualnovel.extension + +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import com.remax.visualnovel.widget.ui.bannerindicator.RectangleIndicator +import com.youth.banner.Banner + +/** + * Created by HJW on 2025/7/31 + */ + +/** + * 安全反转列表,不使用 reversed() 或 asReversed(),避免 NoSuchMethodError。 + * @param input 任意输入(List、null 或其他类型)。 + * @return 反转后的列表,若输入无效则返回空列表。 + */ +fun Any?.safeReverseList(): List { + // 处理 null 输入 + if (this == null) { + return emptyList() + } + + // 检查是否为 List 类型 + if (this !is List<*>) { + return emptyList() + } + + // 创建新的可变列表用于存储反转结果 + val result = mutableListOf() + + // 手动反转:从最后一个元素开始向前遍历 + for (i in this.size - 1 downTo 0) { + try { + // 安全转换元素类型,避免 ClassCastException + @Suppress("UNCHECKED_CAST") + val element = this[i] as T + result.add(element) + } catch (e: ClassCastException) { + // 类型转换失败,记录日志并跳过该元素 + println("跳过无效元素: ${e.message}") + continue + } + } + + // 返回只读列表,防止外部修改 + return result.toList() +} + + +/** + * 将数据按 @param chunkNum 个一页展示在banner上 + * @receiver List 原始数据 + * @param indicator RectangleIndicator banner指示器 + * @param banner Banner<*, *> banner控件 + * @param transform Function1 请求回来的数据转换成UI数据 + * @param placeholderFactory 新增工厂函数,用于创建占位符 + */ +inline fun List.getBannerChunkData( + chunkNum: Int, + banner: Banner<*, *>, + transform: (T) -> U, + placeholderFactory: (U) -> U, + owner: LifecycleOwner? = null, + indicator: RectangleIndicator? = null +): List> { + val splitData = this.map(transform).chunked(chunkNum).toMutableList() + indicator?.isVisible = splitData.size > 1 + /** + * 从第二页开始,补满8个item,预防高度发生变化 + */ + splitData.forEachIndexed { index, rewardUIData -> + if (index > 0 && rewardUIData.size < chunkNum) { + val mutableData = rewardUIData.toMutableList() + val first = mutableData.firstOrNull() + repeat(chunkNum - mutableData.size) { + first?.let { + mutableData.add(placeholderFactory(it)) + } + } + splitData[index] = mutableData + } + } + indicator?.let { + banner.setIndicator(indicator, false) + } + banner.addBannerLifecycleObserver(owner) + return splitData.toList() +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt new file mode 100644 index 0000000..33b3341 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/LongExt.kt @@ -0,0 +1,66 @@ +package com.remax.visualnovel.extension + + +import com.remax.visualnovel.utils.TimeUtils +import java.util.Calendar + +/** + * Created by HJW on 2022/7/28 + */ + +/** + * 计算年龄 + */ +fun Long?.calculateAge(): Int { + val calendar = Calendar.getInstance() + val yearNow = calendar.get(Calendar.YEAR) + + val birth = birthDateFormatParse.format(this) + calendar.time = TimeUtils.getBirthDate(birth) + val year = yearNow - calendar.get(Calendar.YEAR) + + return year +} + +/** + * 检查年龄是否满18岁 + * @receiver Long + * @return Boolean + */ +fun Long.checkAge(): Boolean { + val calendar = Calendar.getInstance() + val yearNow = calendar.get(Calendar.YEAR) + val monthNow = calendar.get(Calendar.MONTH) + val dayNow = calendar.get(Calendar.DATE) + val birth =birthDateFormatParse.format(this) + calendar.time = TimeUtils.getBirthDate(birth) + val year = yearNow - calendar.get(Calendar.YEAR) + val month = monthNow - calendar.get(Calendar.MONTH) + val day = dayNow - calendar.get(Calendar.DATE) + + return when { + year < 18 -> { + false + } + + year == 18 -> { + when { + month < 0 -> { + false + } + + month == 0 -> { + day >= 0 + } + + else -> { + true + } + } + } + + else -> { + true + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt new file mode 100644 index 0000000..198db50 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/MagicIndicatorExt.kt @@ -0,0 +1,148 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentPagerAdapter +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.remax.visualnovel.app.base.BaseCommonNavigatorAdapter +import com.remax.visualnovel.widget.indicator.ViewPager2Helper +import net.lucode.hackware.magicindicator.MagicIndicator +import net.lucode.hackware.magicindicator.ViewPagerHelper +import net.lucode.hackware.magicindicator.buildins.commonnavigator.CommonNavigator + +/** + * Created by HJW on 2023/7/18 + */ + + +/** + * 快速设置MagicIndicator + * @receiver Context 当前上下文 + * @param fragments List + * @param navigationAdapter CommonNavigatorAdapter 传入具体子类实现UI样式 + * @param magicIndicator MagicIndicator + * @param leftPadding Int + * @param rightPadding Int + * @param isAdjustMode Boolean + * @param viewPager ViewPager? 与viewPager绑定 + * @param viewPager2 ViewPager2? 与viewPager2绑定 + * @param currentItem Int + * @param viewPager2PageSelected Function1? + */ +fun Fragment.setMagicIndicator( + fragments: List, + navigationAdapter: BaseCommonNavigatorAdapter, + magicIndicator: MagicIndicator, + leftPadding: Int = 0, + rightPadding: Int = 0, + isAdjustMode: Boolean = false, + currentItem: Int = 0, + isUserInputEnabled: Boolean = true, + viewPager2PageSelected: ((Int) -> Unit)? = null +) { + requireContext().setMagicIndicatorCommon( + fragments, + navigationAdapter, + magicIndicator, + leftPadding, + rightPadding, + isAdjustMode, + currentItem, + isUserInputEnabled, + viewPager2PageSelected + ) + + navigationAdapter.viewPager2?.adapter = object : FragmentStateAdapter(this) { + override fun getItemCount() = navigationAdapter.count + override fun createFragment(position: Int): Fragment = fragments[position] + } + + navigationAdapter.viewPager?.adapter = object : FragmentPagerAdapter(childFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getItem(position: Int): Fragment = fragments[position] + override fun getCount() = fragments.size + } + +} + +fun FragmentActivity.setMagicIndicator( + fragments: List, + navigationAdapter: BaseCommonNavigatorAdapter, + magicIndicator: MagicIndicator, + leftPadding: Int = 0, + rightPadding: Int = 0, + isAdjustMode: Boolean = false, + currentItem: Int = 0, + isUserInputEnabled: Boolean = true, + viewPager2PageSelected: ((Int) -> Unit)? = null +) { + navigationAdapter.viewPager2?.adapter = object : FragmentStateAdapter(this) { + override fun getItemCount() = navigationAdapter.count + override fun createFragment(position: Int): Fragment = fragments[position] + } + + navigationAdapter.viewPager?.adapter = object : FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getItem(position: Int): Fragment = fragments[position] + override fun getCount() = fragments.size + } + + setMagicIndicatorCommon( + fragments, + navigationAdapter, + magicIndicator, + leftPadding, + rightPadding, + isAdjustMode, + currentItem, + isUserInputEnabled, + viewPager2PageSelected + ) + +} + +private fun Context.setMagicIndicatorCommon( + fragments: List, + navigationAdapter: BaseCommonNavigatorAdapter, + magicIndicator: MagicIndicator, + leftPadding: Int = 0, + rightPadding: Int = 0, + isAdjustMode: Boolean = false, + currentItem: Int = 0, + isUserInputEnabled: Boolean = true, + viewPager2PageSelected: ((Int) -> Unit)? = null +) { + val commonNavigator = CommonNavigator(this).apply { + adapter = navigationAdapter + this.isAdjustMode = isAdjustMode + this.leftPadding = leftPadding + this.rightPadding = rightPadding + } + magicIndicator.navigator = commonNavigator + + navigationAdapter.viewPager?.let { + ViewPagerHelper.bind(magicIndicator, it) + if (fragments.isNotEmpty()) { + it.offscreenPageLimit = fragments.size + } + if (currentItem < fragments.size) { + it.doOnPreDraw { _ -> + it.currentItem = currentItem + } + } + } + navigationAdapter.viewPager2?.let { + ViewPager2Helper.bind(magicIndicator, it) { position -> + viewPager2PageSelected?.invoke(position) + } + if (fragments.isNotEmpty()) { + it.offscreenPageLimit = fragments.size + } + if (currentItem < fragments.size) { + it.doOnPreDraw { _ -> + it.currentItem = currentItem + } + } + it.isUserInputEnabled = isUserInputEnabled + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt new file mode 100644 index 0000000..184c864 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/Postcard.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.extension + +import androidx.appcompat.app.AppCompatActivity +import com.remax.visualnovel.R + +/** + * Activity打开动画 从下往上 + */ +fun AppCompatActivity.withTransitionFromBottom() { + overridePendingTransition(R.anim.act_slide_in_from_bottom, R.anim.no_anim) +} + + +fun AppCompatActivity.transitionFromBottom() { + overridePendingTransition(R.anim.no_anim, R.anim.act_slide_out_from_bottom) +} + + +fun AppCompatActivity.withTransitionFromAlpha() { + overridePendingTransition(R.anim.dialog_alpha_show, R.anim.no_anim) +} + + +fun AppCompatActivity.transitionFromAlpha() { + overridePendingTransition(R.anim.no_anim, R.anim.dialog_alpha_cancel) +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt new file mode 100644 index 0000000..486720a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/RecyclerViewExt.kt @@ -0,0 +1,52 @@ +package com.remax.visualnovel.extension + +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView +import com.scwang.smart.refresh.layout.SmartRefreshLayout +import kotlin.math.abs + +/** + * Created by HJW on 2022/11/16 + */ + +/** + * 搜索页的RecyclerView 点击无子view的地方回调 + */ +fun RecyclerView.setSearchClosePageEvent(nullViewCallback: () -> Unit) { + addOnItemTouchListener(object : RecyclerView.OnItemTouchListener { + var dx = 0f + var dy = 0f + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.action) { + MotionEvent.ACTION_MOVE -> { + } + + MotionEvent.ACTION_UP -> { + val x = dx - e.x + if (abs(x) < 30) { + val view = rv.findChildViewUnder(e.x, e.y) + if (view == null) { + nullViewCallback.invoke() + } + } + } + + MotionEvent.ACTION_DOWN -> { + dx = e.x + dy = e.y + } + } + return false + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + } + }) +} + +fun SmartRefreshLayout.autoRefreshList(animationOnly: Boolean = false) { + autoRefresh(400, 300, 1f, animationOnly) +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt new file mode 100644 index 0000000..129c486 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ResourcesExt.kt @@ -0,0 +1,24 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import android.content.res.Resources + +/** + * Created by HJW on 2023/3/17 + */ + +/** + * 字体大小不随着系统大小变动 + */ +fun Resources.fixedFontSize(context: Context): Resources { + var resources = this + val newConfig = resources.configuration + val displayMetrics = resources.displayMetrics + if (newConfig.fontScale != 1f) { + newConfig.fontScale = 1f + val configurationContext = context.createConfigurationContext(newConfig) + resources = configurationContext.resources + displayMetrics.scaledDensity = displayMetrics.density * newConfig.fontScale + } + return resources +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt new file mode 100644 index 0000000..8a43edf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ShowAndHideExt.kt @@ -0,0 +1,141 @@ +package com.remax.visualnovel.extension + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle + +/** + * Created by HJW on 2021/1/6 + */ + + +/*** + * 修改ViewPager 适配器为 + * FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) + FragmentStatePagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) + */ + +/** + * 加载根Fragment + * @param containerViewId 布局id + * @param rootFragment 根fragment + */ +fun Fragment.loadRootFragment(@IdRes containerViewId: Int, rootFragment: Fragment) { + loadFragmentsTransaction(containerViewId, 0, childFragmentManager, rootFragment) +} + +/** + * 加载同级的Fragment + * @param containerViewId 布局id + * @param showPosition 默认显示的角标 + * @param fragments 加载的fragment + */ +fun Fragment.loadFragments( + @IdRes containerViewId: Int, + showPosition: Int = 0, + vararg fragments: Fragment +) { + loadFragmentsTransaction(containerViewId, showPosition, childFragmentManager, *fragments) +} + +/** + * 显示目标fragment,并隐藏其他fragment + * @param showFragment 需要显示的fragment + */ +fun Fragment.showHideFragment(showFragment: Fragment) { + showHideFragmentTransaction(childFragmentManager, showFragment) +} + + +/** + * 加载根Fragment + * @param containerViewId 布局id + * @param rootFragment 根fragment + */ +fun FragmentActivity.loadRootFragment(@IdRes containerViewId: Int, rootFragment: Fragment) { + loadFragmentsTransaction(containerViewId, 0, supportFragmentManager, rootFragment) +} + +/** + * 加载同级的Fragment + * @param containerViewId 布局id + * @param showPosition 默认显示的角标 + * @param fragments 加载的fragment + */ +fun FragmentActivity.loadFragments( + @IdRes containerViewId: Int, + showPosition: Int = 0, + vararg fragments: Fragment +) { + loadFragmentsTransaction(containerViewId, showPosition, supportFragmentManager, *fragments) +} + +/** + * 显示目标fragment,并隐藏其他fragment + * @param showFragment 需要显示的fragment + */ +fun FragmentActivity.showHideFragment(showFragment: Fragment) { + showHideFragmentTransaction(supportFragmentManager, showFragment) +} + +/** + * 使用add+show+hide模式加载fragment + * + * 默认显示位置[showPosition]的Fragment,最大Lifecycle为Lifecycle.State.RESUMED + * 其他隐藏的Fragment,最大Lifecycle为Lifecycle.State.STARTED + * + *@param containerViewId 容器id + *@param showPosition fragments + *@param fragmentManager FragmentManager + *@param fragments 控制显示的Fragments + */ +private fun loadFragmentsTransaction( + @IdRes containerViewId: Int, + showPosition: Int, + fragmentManager: FragmentManager, + vararg fragments: Fragment +) { + if (fragments.isNotEmpty()) { + fragmentManager.beginTransaction().apply { + for (index in fragments.indices) { + val fragment = fragments[index] + add(containerViewId, fragment, fragment.javaClass.name) + if (showPosition == index) { + setMaxLifecycle(fragment, Lifecycle.State.RESUMED) + } else { + hide(fragment) + setMaxLifecycle(fragment, Lifecycle.State.STARTED) + } + } + + }.commit() + } else { + throw IllegalStateException( + "fragments must not empty" + ) + } +} + +/** + * 显示需要显示的Fragment[showFragment],并设置其最大Lifecycle为Lifecycle.State.RESUMED。 + * 同时隐藏其他Fragment,并设置最大Lifecycle为Lifecycle.State.STARTED + * @param fragmentManager + * @param showFragment + */ +private fun showHideFragmentTransaction(fragmentManager: FragmentManager, showFragment: Fragment) { + fragmentManager.beginTransaction().apply { + show(showFragment) + setMaxLifecycle(showFragment, Lifecycle.State.RESUMED) + + //获取其中所有的fragment,其他的fragment进行隐藏 + val fragments = fragmentManager.fragments + for (fragment in fragments) { + if (fragment != showFragment) { + hide(fragment) + setMaxLifecycle(fragment, Lifecycle.State.STARTED) + } + } + }.commit() +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt new file mode 100644 index 0000000..10d015d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/StringExt.kt @@ -0,0 +1,100 @@ +package com.remax.visualnovel.extension + +import com.remax.visualnovel.constant.AppStatus +import com.remax.visualnovel.utils.spannablex.utils.dp +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * Created by HJW on 2020/9/10 + */ +fun String?.md5Encode(): String { + if (this == null) { + return "" + } + try { + //获取md5加密对象 + val instance: MessageDigest = MessageDigest.getInstance("MD5") + //对字符串加密,返回字节数组 + val digest: ByteArray = instance.digest(this.toByteArray()) + val sb: StringBuffer = StringBuffer() + for (b in digest) { + //获取低八位有效值 + val i: Int = b.toInt() and 0xff + //将整数转化为16进制 + var hexString = Integer.toHexString(i) + if (hexString.length < 2) { + //如果是一位的话,补0 + hexString = "0$hexString" + } + sb.append(hexString) + } + return sb.toString() + + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } + + return "" +} + +/** + * 处理图片压缩 + * + */ +fun String?.toS3Url(wid: Int, hei: Int? = null, isDp: Boolean = true, isFill: Boolean = hei != null): String { + if (isNullOrEmpty()) return "" + return when { + this.endsWith(".gif") -> { + this + } + + else -> { + val resizeWid = if (isDp) wid.dp else wid + val resizeHei = if (hei != null) { + if (isDp) hei.dp else hei + } else { + resizeWid + } + "$this?x-oss-process=image/resize${if (isFill) ",m_fill" else ""},w_${resizeWid},h_${resizeHei}" + } + } +} + +/** + * 图片新增方向裁剪 + * 以宽为基准 w,1 + * 以高为基准 h,1 + */ +fun String?.toCropS3Url(isWidth: Boolean, radio: Float) = "${this ?: ""}&m_crop=${if (isWidth) "w" else "h"},$radio" + + +// 正则表达式匹配 () ,包括中英文的,不考虑嵌套 +fun String?.extractBrackets(): List { + val regex = Regex("(\\(.*?\\)|(.*?))") + // 按文本顺序提取所有匹配的括号对 + return regex.findAll(this ?: "").map { it.value }.toList() +} + +/** + * 获取云信的ID + */ +fun String?.getNimAccountId(isAI: Boolean) = this + + if (isAI) { + if (AppStatus.isProduct) "@r" else "@r@t" + } else { + if (AppStatus.isProduct) "@u" else "@u@t" + } + + +/** + * 检查长度 + */ +fun String?.checkLength(minLength: Int, allowEmpty: Boolean = false): Boolean { + return when { + allowEmpty && this.isNullOrBlank() -> true + minLength <= 0 && this.isNullOrBlank() -> false + (this?.trim()?.length ?: 0) < minLength -> false + else -> true + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt new file mode 100644 index 0000000..a1f69bc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/SwitchViewExt.kt @@ -0,0 +1,34 @@ +package com.remax.visualnovel.extension + +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.widget.ui.SwitchView + +/** + * Created by HJW on 2023/11/13 + */ + +/** + * 批量操作此页面 Switch + * @receiver SwitchView + * @param switchChecked Boolean 开关是否打开 null表示不操作 + * @param switchLayouts ViewGroup? SwitchView的相关父布局 + * @param switchIsVisible Boolean SwitchView的父布局是否隐藏 + * @param switchCheckedCallback Function0 开关切换callback + */ +internal fun SwitchView.setSwitchShowAndChecked( + switchChecked: Boolean? = null, + switchLayouts: List? = null, + switchIsVisible: Boolean = false, + switchCheckedCallback: (checked: Boolean) -> Unit +) { + switchLayouts?.forEach { + it.isVisible = switchIsVisible + } + switchChecked?.let { + isChecked = it + } + setPressChanged { + switchCheckedCallback(isChecked) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt new file mode 100644 index 0000000..53e84b7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/TextViewExt.kt @@ -0,0 +1,106 @@ +package com.remax.visualnovel.extension + +import android.content.Context +import android.graphics.LinearGradient +import android.graphics.Shader +import android.text.Layout +import android.text.StaticLayout +import android.view.View +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.constant.Gender +import com.remax.visualnovel.utils.SpanUtils +import com.remax.visualnovel.utils.spannablex.SpanDsl +import com.remax.visualnovel.widget.uitoken.handleUIToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +fun TextView.setUnReadCount(num: Int?) { + val realNum = num ?: 0 + text = if (realNum > 99) "···" else realNum.toString() + isVisible = realNum > 0 +} + +fun TextView.setGenderLeft(gender: Int?) { + when (gender) { + Gender.MALE.value -> drawableLeft(R.mipmap.ic_gender_male) + + Gender.FEMALE.value -> drawableLeft(R.mipmap.ic_gender_female) + + Gender.NONCONFORMING.value -> drawableLeft(R.mipmap.ic_gender_nonconforming) + + Gender.OTHER.value -> drawableLeft(R.mipmap.ic_gender_nonconforming) + } +} + +fun TextView.drawableLeft(resId: Int) { + setCompoundDrawablesRelativeWithIntrinsicBounds(resId, 0, 0, 0) +} + +fun TextView.drawableRight(resId: Int) { + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, resId, 0) +} + +fun Int?.getSexText() = when (this) { + Gender.MALE.value -> CommonApplicationProxy.application.getString(Gender.MALE.txtRes) + + Gender.FEMALE.value -> CommonApplicationProxy.application.getString(Gender.FEMALE.txtRes) + + Gender.NONCONFORMING.value -> CommonApplicationProxy.application.getString(Gender.NONCONFORMING.txtRes) + + Gender.OTHER.value -> CommonApplicationProxy.application.getString(Gender.OTHER.txtRes) + + else -> "" +} + +/** + * 快速根据token设置字体 + * @receiver Context + * @param fontToken Int token资源 + */ +fun SpanDsl.setSpanTypeFace(context: Context, @StringRes fontToken: Int = 0) { + context.handleUIToken(fontToken)?.textFont?.run { + typeface(context.getTextFontTypeface(this.typeFace)) + absoluteSize(this.textFontSize.toInt(), false) + } +} + +fun TextView.showGradientColor(colors: IntArray, content: String? = null) { + SpanUtils.with(this).append(content ?: "").setShader( + LinearGradient( + 0f, + 0f, + paint.measureText(content ?: ""), + 0f, + colors, + null, + Shader.TileMode.CLAMP + ) + ).create() +} + +fun TextView.calculateLineCount(content: String?, availableWidth: Int, introAll: View) { + introAll.isVisible = false + CoroutineScope(Dispatchers.IO).launch { + val staticLayout = StaticLayout.Builder + .obtain(content ?: "", 0, content?.length ?: 0, paint, availableWidth) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) + .setIncludePad(includeFontPadding) + .build() + val lineCount = staticLayout.lineCount + withContext(Dispatchers.Main) { + if (lineCount > 3) { + maxLines = 3 + introAll.isVisible = true + } + text = content + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt new file mode 100644 index 0000000..1f8574d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ViewExt.kt @@ -0,0 +1,116 @@ +package com.remax.visualnovel.extension + +import android.animation.ObjectAnimator +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.MarginLayoutParams +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.EditText +import androidx.core.view.updateLayoutParams +import com.remax.visualnovel.utils.KeyboardUtils +import com.google.android.material.appbar.AppBarLayout + +/** + * Created by HJW on 2021/4/1 + */ + +/** + * 标题arrow旋转动画 + */ +fun View.navRotationOpen(isOpen: Boolean) { + val end: Float = if (isOpen) 180f else 0.toFloat() + val start: Float = if (isOpen) 0f else 180.toFloat() + ObjectAnimator.ofFloat(this, "rotation", start, end).setDuration(200).start() +} + +/** + * 在editText上显示键盘 + */ +fun View.showKeyboard() { + (this as? EditText)?.run { + setSelection(text.toString().length) + } + KeyboardUtils.showSoftInput(this) +} + +fun View.setMargin(marginStart: Int? = null, topMargin: Int? = null, marginEnd: Int? = null, bottomMargin: Int? = null) { + (layoutParams as? MarginLayoutParams)?.let { + updateLayoutParams { + marginStart?.run(this::setMarginStart) + marginEnd?.run(this::setMarginEnd) + if (topMargin != null) this.topMargin = topMargin + if (bottomMargin != null) this.bottomMargin = bottomMargin + } + } +} + +fun View.setSize(width: Int? = null, height: Int? = null): Pair { + if (layoutParams == null) { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + updateLayoutParams { + width?.let { + this.width = it + } + height?.let { + this.height = it + } + } + return Pair(width, height) +} + +fun View.setAllParentClipChildren() { + if (parent is ViewGroup && parent !is AppBarLayout) { + (parent as? ViewGroup)?.run { + clipToPadding = false + clipChildren = false + setAllParentClipChildren() + } + } +} + +fun View.inRangeOfView(ev: MotionEvent): Boolean { + val location = IntArray(2) + this.getLocationOnScreen(location) + val x = location[0] + val y = location[1] + return !(ev.x < x || ev.x > x + this.width || ev.y < y || ev.y > y + this.height) +} + +fun View?.isShouldHideKeyboard(event: MotionEvent): Boolean { + if (this != null) { //判断得到的焦点控件是否包含EditText + val l = intArrayOf(0, 0) + this.getLocationInWindow(l) + val left = l[0] + //得到输入框在屏幕中上下左右的位置 + val top = l[1] + val bottom = top + this.height + val right = left + this.width + return !(event.x > left && event.x < right && event.y > top && event.y < bottom) + } + // 如果焦点不是EditText则忽略 + return false +} + + +fun View.translationYObjectAnimator( + translationY: Float, + duration: Long, + onEnd: (() -> Unit)? = null +) { + val animatorY = ObjectAnimator.ofFloat(this, "translationY", translationY) + + animatorY.apply { + this.duration = duration + interpolator = AccelerateDecelerateInterpolator() + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + onEnd?.invoke() + } + }) + } + animatorY.start() +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt new file mode 100644 index 0000000..f03a706 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/WindowExt.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.extension + +import android.app.Activity +import android.view.View +import com.remax.visualnovel.utils.StatusBarUtils + +/** + * Created by HJW on 2023/3/20 + */ + +@Suppress("DEPRECATION") +fun Activity.changeStatusBarVisibility(isLand: Boolean) { + if (isLand) { + // 隐藏状态栏和导航栏,拉出状态栏和导航栏显示一会儿后消失。 + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + } else { + //显示状态栏和导航栏 + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE + StatusBarUtils.setTransparent(this) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt new file mode 100644 index 0000000..0c83201 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/glide/GlideExt.kt @@ -0,0 +1,259 @@ +package com.remax.visualnovel.extension.glide + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.scale +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.webp.decoder.WebpDrawable +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.remax.visualnovel.extension.toS3Url +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.glidetransformation.CropRectTransformation +import jp.wasabeef.glide.transformations.RoundedCornersTransformation +import timber.log.Timber +import ua.anatolii.graphics.ninepatch.NinePatchChunk + + +fun ImageView.loadGranularRoundedCorners( + url: String?, + topLeft: Int = 0, + topRight: Int = 0, + bottomRight: Int = 0, + bottomLeft: Int = 0, + cache: Boolean = false +) { + Glide.with(context) + .load(url) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .apply( + RequestOptions.bitmapTransform( + MultiTransformation( + CenterCrop(), + GranularRoundedCorners( + topLeft.dp.toFloat(), + topRight.dp.toFloat(), + bottomRight.dp.toFloat(), + bottomLeft.dp.toFloat() + ) + ) + ) + ).into(this) +} + +fun ImageView.loadNoCenterCrop(url: String?) { + visibility = View.VISIBLE + Glide.with(context) + .load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.loadFitCenter(url: String?, cache: Boolean = true) { + visibility = View.VISIBLE + Glide.with(context) + .load(url) + .skipMemoryCache(!cache) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .into(this) +} + + +fun ImageView.loadWebp(url: String?) { + isVisible = true + Glide.with(context) + .load(url) + .addListener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + return false + } + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + (resource as? WebpDrawable)?.loopCount = -1 + return false + } + }) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.load(url: String?) { + visibility = View.VISIBLE + Glide.with(context) + .load(url) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun TextView.loadNinePatch(nineImageUrl: String?) { + Glide.with(context) + .asBitmap() + .load(nineImageUrl) + .into(object : CustomTarget() { + override fun onResourceReady( + bitmap: Bitmap, + transition: Transition? + ) { + val drawable = NinePatchChunk.create9PatchDrawable(context, bitmap, null) + Timber.e("点9图 drawable :$drawable") + background = drawable + } + + override fun onLoadCleared(placeholder: Drawable?) { + + } + }) +} + +fun ImageView.load(drawable: Drawable?) { + visibility = View.VISIBLE + Glide.with(context) + .load(drawable) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.loadToOriginal(url: String?, width: Int, height: Int, cache: Boolean = true) { + Glide.with(context) + .load(url) + .override(width, height) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .into(this) +} + +fun ImageView.loadAndCircleCrop(url: String?) { + Glide.with(context).load(url).diskCacheStrategy(DiskCacheStrategy.AUTOMATIC).circleCrop().into(this) +} + +fun ImageView.loadAndRoundCorner( + url: String?, + radius: Int, + margin: Int = 0, + cache: Boolean = true, + cornerType: RoundedCornersTransformation.CornerType = RoundedCornersTransformation.CornerType.ALL +) { + val transformation = MultiTransformation( + CenterCrop(), + RoundedCornersTransformation(radius.dp, margin, cornerType) + ) + Glide.with(context).load(url) + .skipMemoryCache(!cache) + .transform(transformation) + .diskCacheStrategy(if (cache) DiskCacheStrategy.AUTOMATIC else DiskCacheStrategy.NONE) + .into(this) +} + +fun ImageView.loadResAndRoundCorner( + res: Int, + radius: Int, + margin: Int = 0, + cornerType: RoundedCornersTransformation.CornerType = RoundedCornersTransformation.CornerType.ALL +) { + val transformation = MultiTransformation( + CenterCrop(), + RoundedCornersTransformation(radius.dp, margin, cornerType) + ) + Glide.with(context).load(res) + .transform(transformation) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + +fun ImageView.loadAndRoundCornerToOriginal(url: String?, radius: Int, margin: Int = 0, width: Int, height: Int) { + val transformation = MultiTransformation( + CenterCrop(), + RoundedCornersTransformation(radius.dp, margin) + ) + Glide.with(context) + .load(url) + .override(width, height) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .centerCrop() + .transform(transformation) + .into(this) +} + +fun ImageView.loadAndCropTransformation( + url: String?, + width: Int, + height: Int, + cropXType: CropRectTransformation.CropXType = CropRectTransformation.CropXType.TOP, + cropYType: CropRectTransformation.CropYType = CropRectTransformation.CropYType.CENTER, + radius: Int = 0 +) { + Glide.with(context) + .load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .apply( + RequestOptions.bitmapTransform( + MultiTransformation( + CropRectTransformation(width, height, cropXType, cropYType), + RoundedCornersTransformation(radius.dp, 0) + ) + ) + ) + .into(this) +} + +fun ImageView.loadAndCropTransformation( + @DrawableRes res: Int, + width: Int, + height: Int, + cropXType: CropRectTransformation.CropXType = CropRectTransformation.CropXType.TOP, + cropYType: CropRectTransformation.CropYType = CropRectTransformation.CropYType.CENTER, + radius: Int = 0 +) { + Glide.with(context) + .load(ContextCompat.getDrawable(context, res)) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .apply( + RequestOptions.bitmapTransform( + MultiTransformation( + CropRectTransformation(width, height, cropXType, cropYType), + RoundedCornersTransformation(radius.dp, 0) + ) + ) + ) + .into(this) +} + + +fun ImageView.loadCircleAvatar(url: String?, wid: Int, hei: Int? = null) { + this.isVisible = true + val processImage = url?.toS3Url(wid, hei, isDp = false, isFill = false) + Glide.with(context) + .load(processImage) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .into(this) +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt new file mode 100644 index 0000000..3c8dd3b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/extension/ui/IconButtonViewExt.kt @@ -0,0 +1,23 @@ +package com.remax.visualnovel.extension.ui + +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.ui.buttons.IconButtonView +import com.remax.visualnovel.widget.uitoken.changeTextColor + +/** + * Created by HJW on 2023/7/24 + */ + +fun IconButtonView.setFavorite(userFavorite: Boolean) { + if (userFavorite) { + setText(R.string.icon_star_fill) + changeTextColor { + textUIColorToken = context.getString(R.string.color_warning_gradient_normal) + } + } else { + setText(R.string.icon_star) + changeTextColor { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt new file mode 100644 index 0000000..e7a5fc2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/CustomToastManager.kt @@ -0,0 +1,67 @@ +package com.remax.visualnovel.manager + + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.widget.Toast +import java.lang.ref.SoftReference + +/** + * Created by HJW on 2021/1/7 + */ +object CustomToastManager { + private var toastReference: SoftReference? = null + + private var isShowingToast = false + + class MyHandler : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + super.handleMessage(msg) + when (msg.what) { + 0 -> { + isShowingToast = false + } + } + } + } + + private lateinit var handler: MyHandler + + fun showToast(context: Context, content: String?, isLong: Boolean = false) { + if (content.isNullOrBlank()) return + if (toastReference == null || toastReference?.get() == null) { + val toast = createToast(context, content) + toastReference = SoftReference(toast) + } + toastReference?.get()?.let { + if (!isShowingToast) { + isShowingToast = true + it.setText(content) + it.duration = if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + it.show() + val time = 1000L * 2 + if (this::handler.isInitialized) { + handler.sendEmptyMessageDelayed(0, time) + } else { + handler = MyHandler() + handler.sendEmptyMessageDelayed(0, time) + } + } else { + it.setText(content) + } + } + } + + fun hideToast() { + toastReference?.get()?.cancel() + } + + private fun createToast(context: Context, content: String): Toast { + handler = MyHandler() + return Toast.makeText(context, content, Toast.LENGTH_SHORT) + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt new file mode 100644 index 0000000..1d21f78 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginInfoSave.kt @@ -0,0 +1,35 @@ +package com.remax.visualnovel.manager.login + +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.repository.mmkv.UserLocalRepository + + +/** + * Created by HJW on 2023/3/17 + * + * 登录要保存/读取的相关数据 + */ +class LoginInfoSave { + + /** + * 保存登录的user + */ + fun putUser(user: User?) { + UserLocalRepository.localUser = user + } + + fun getUser() = UserLocalRepository.localUser + + /** + * 保存token + */ + fun putToken(token: String?) { + UserLocalRepository.token = token ?: "" + } + + fun getToken(): String { + return UserLocalRepository.token + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt new file mode 100644 index 0000000..468b199 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/manager/login/LoginManager.kt @@ -0,0 +1,86 @@ +package com.remax.visualnovel.manager.login + +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.event.model.OnLoginEvent +import com.remax.visualnovel.event.model.tab.MainTab +import com.remax.visualnovel.utils.Routers +import com.remax.visualnovel.ui.main.MainActivity + +/** + * Created by HJW on 2025/7/11 + */ +object LoginManager { + + private val loginInfoSave by lazy { + LoginInfoSave() + } + + var user: User? = null + set(value) { + loginInfoSave.putUser(value) + field = value + } + + var token: String? = null + set(value) { + loginInfoSave.putToken(value) + field = value + } + + val isLogin: Boolean + get() = user != null + + val isVip: Boolean + get() = user?.isMember == true + + fun isMyself(userId: String?): Boolean { + return isLogin && userId == user?.userId + } + + fun init() { + user = loginInfoSave.getUser() + token = loginInfoSave.getToken() + } + + /** + * 退出登录 + */ + fun logout() { + user = null + token = null + EventDefineOfUserEvents.onLoginStatusChanged().post(OnLoginEvent(OnLoginEvent.LOGOUT)) + MainActivity.start(MainTab.TAB_BOOKS) + } + + /** + * 检查登录状态 + */ + fun checkLogin(callback: (() -> Unit)? = null) { + if (isLogin) { + callback?.invoke() + } else { + // 未登录授权 总是去登录页面 + Routers.navigation(Routers.LOGIN) + } + } + + fun putUser(user: User?) { + this.user = user + } + + fun putToken(token: String?) { + this.token = token + } + + + /*private val messageRepository by lazy { + MessageRepository(ServiceFactory.createService()) + } + + fun refreshUnreadCount() { + CoroutineScope(Dispatchers.Main).launch { + messageRepository.getMessageStat() + } + }*/ +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt new file mode 100644 index 0000000..291e44a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/BooksRepository.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.entity.response.base.Response +import javax.inject.Inject + + +class BooksRepository @Inject constructor(private val bookService: BookService) : BaseRepository() { + suspend fun getBooks(): Response { + return bookService.getBooks() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt new file mode 100644 index 0000000..3dacf11 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/DictRepository.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.repository.api + + +import com.remax.visualnovel.api.service.DictService +import com.remax.visualnovel.repository.api.base.BaseRepository +import javax.inject.Inject + +/** + * Created by HJW on 2022/11/15 + */ +class DictRepository @Inject constructor(private val dictService: DictService) : BaseRepository() { + + /*suspend fun getChatBubbleList(aiId: String) = executeHttp { + dictService.getChatBubbleList(AIIDRequest(aiId)) + } + + suspend fun getAIDict() = executeHttp { + dictService.getAIDict() + } + + suspend fun getGiftDict() = executeHttp(false) { dictService.getGiftDict() } + + suspend fun getAIChatModel() = executeHttp { dictService.getAIChatModel() }*/ + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt new file mode 100644 index 0000000..a6d915f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/HistoryRepository.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.entity.response.base.Response +import javax.inject.Inject + + +class HistoryRepository @Inject constructor(private val bookService: BookService) : BaseRepository() { + suspend fun getBooks(): Response { + return bookService.getBooks() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt new file mode 100644 index 0000000..d14ae23 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/LoginRepository.kt @@ -0,0 +1,50 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.api.service.LoginService +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.entity.request.PlatformAccountVerifyDTO +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.event.model.OnLoginEvent +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 用户相关 + */ +open class LoginRepository @Inject constructor( + private val loginService: LoginService +) : BaseRepository() { + + suspend fun checkUserNickname(nickname: String?,exUserId:String?) = executeHttp { + val request = CompleteUserInfoInput(nickname = nickname, exUserId = exUserId) + loginService.checkUserNickname(request) + } + + suspend fun platformThirdVerify(thirdToken: String, thirdType: String) = executeHttp { + loginService.platformThirdVerify(PlatformAccountVerifyDTO(thirdToken, thirdType)) + }.transformResult({ + if (it?.isLogin == true) { + LoginManager.putToken(it.token) + } + }) + + /** + * 退出登录 + */ + suspend fun logout(): Response = executeHttp { + loginService.logout() + }.transformResult({ + LoginManager.logout() + }) + + suspend fun register(request: CompleteUserInfoInput) = executeHttp { + loginService.register(request) + }.transformResult({ + EventDefineOfUserEvents.onUserInfoChanged().post(null) + }) + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt new file mode 100644 index 0000000..34c3781 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/ManagasRepository.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.remax.visualnovel.api.service.BookService +import com.remax.visualnovel.entity.response.base.Response +import javax.inject.Inject + + +class ManagasRepository @Inject constructor(private val bookService: BookService) : BaseRepository() { + suspend fun getBooks(): Response { + return bookService.getBooks() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt new file mode 100644 index 0000000..5c68926 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/MessageRepository.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.repository.api + + +import com.remax.visualnovel.api.service.MessageService +import com.remax.visualnovel.repository.api.base.BaseRepository +import javax.inject.Inject + + +/** + * Created by HJW on 2022/11/15 + */ +class MessageRepository @Inject constructor(private val messageService: MessageService) : BaseRepository() { + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt new file mode 100644 index 0000000..ac465cb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/UserRepository.kt @@ -0,0 +1,58 @@ +package com.remax.visualnovel.repository.api + +import com.remax.visualnovel.api.service.UserService +import com.remax.visualnovel.entity.request.CompleteUserInfoInput +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.repository.api.base.BaseRepository +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import javax.inject.Inject + +/** + * Created by HJW on 2022/10/28 + * + * 用户相关 + */ +open class UserRepository @Inject constructor( + private val userService: UserService +) : BaseRepository() { + + suspend fun signToday() = executeHttp { + userService.signToday() + } + + /*suspend fun getSignList() = executeHttp { + userService.getSignList() + }*/ + + suspend fun updateUserInfo(request: CompleteUserInfoInput) = executeHttp { + userService.updateUserInfo(request) + }.transformResult({ + EventDefineOfUserEvents.onUserInfoChanged().post(null) + }) + + /** + * 获取当前登录用户的基本信息,并且更新本地信息 + */ + suspend fun getMyBaseInfo() = executeHttp { + userService.getMyBaseInfo() + }.transformResult({ + LoginManager.putUser(it) + }) + + suspend fun getMyCharactersList() = executeHttp { + userService.getMyCharactersList() + } + + suspend fun deleteAccount() = executeHttp { + userService.deleteAccount() + }.transformResult({ + LoginManager.logout() + }) + + /** + * 获取当前登录用户的网易云信信息 + */ + /*suspend fun getNimInfo() = executeHttp(false) { + userService.getNimInfo() + }*/ +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt new file mode 100644 index 0000000..030609f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/api/base/BaseRepository.kt @@ -0,0 +1,94 @@ +package com.remax.visualnovel.repository.api.base + + +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.constant.StatusCode +import com.remax.visualnovel.entity.response.base.ApiEmptyResponse +import com.remax.visualnovel.entity.response.base.ApiFailedResponse +import com.remax.visualnovel.entity.response.base.ApiSuccessResponse +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.manager.login.LoginManager +import timber.log.Timber + +open class BaseRepository { + + /** + * 如果不需要检查登录,那么在未登录的情况下 直接返回Success + * @param checkLogin 检查登录,默认都需要检查 + */ + suspend fun executeHttp(checkLogin: Boolean = true, block: suspend () -> Response): Response { + return if (!checkLogin) { + if (LoginManager.isLogin) { + execute(block) + } else { + ApiSuccessResponse() + } + } else { + execute(block) + } + } + + private suspend fun execute(block: suspend () -> Response): Response { + return try { + val data = block.invoke() + handleHttpOk(data) + } catch (e: Exception) { + handleHttpError(e) + } + } + + /** + * 非后台返回错误,捕获到的异常 + */ + private fun handleHttpError(e: Throwable): ApiFailedResponse { + Timber.e("responseAsync error -> ${e.localizedMessage}") + val errorMsg = CommonApplicationProxy.application.getString(R.string.your_network_error) + return ApiFailedResponse(errorMsg = errorMsg) + } + + /** + * http返回200,还要判断后端业务层isSuccess + */ + private fun handleHttpOk(response: Response): Response { + return when { + //后端业务正常 + response.isOk -> { + getHttpSuccessResponse(response) + } + + //登录超时 + response.errorCode == StatusCode.TOKEN_EXPIRED.code -> { + CommonApplicationProxy.application.toast(response.errorMsg) + LoginManager.logout() + ApiFailedResponse(response.errorCode, response.errorMsg) + } + + //余额不足 + response.errorCode == StatusCode.INSUFFICIENT_BALANCE.code -> { + /*WalletManager.refreshWallet() + WalletManager.showChargeDialog()*/ + ApiFailedResponse(response.errorCode, response.errorMsg) + } + + else -> { + ApiFailedResponse(response.errorCode, response.errorMsg) + } + } + } + + /** + * 成功和数据为空的处理 + */ + private fun getHttpSuccessResponse(response: Response): Response { + val data = response.data +// return if (data == null || data is List<*> && (data as List<*>).isEmpty()) { + return if (data == null) { + ApiEmptyResponse() + } else { + ApiSuccessResponse(response.data) + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt new file mode 100644 index 0000000..3578c9b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/SystemRepository.kt @@ -0,0 +1,21 @@ +package com.remax.visualnovel.repository.mmkv + +import com.remax.visualnovel.utils.mmkv.MMKVOwner +import com.remax.visualnovel.utils.mmkv.mmkvBool + +/** + * Created by HJW on 2022/8/30 + * 存储设备的本地数据,与登录用户无关 + */ +object SystemRepository : MMKVOwner("system") { + + /** + * 心动朋友列表页首次说明 + */ + var heartBeatDesc by mmkvBool() + + + var chatBubbleBadge by mmkvBool() + var chatBackgroundBadge by mmkvBool() + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt new file mode 100644 index 0000000..d4330f8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/repository/mmkv/UserLocalRepository.kt @@ -0,0 +1,18 @@ +package com.remax.visualnovel.repository.mmkv + +import com.remax.visualnovel.entity.response.User +import com.remax.visualnovel.utils.mmkv.MMKVOwner +import com.remax.visualnovel.utils.mmkv.mmkvParcelable +import com.remax.visualnovel.utils.mmkv.mmkvString + +/** + * Created by HJW on 2022/8/22 + * 登录用户相关数据 + */ +object UserLocalRepository : MMKVOwner("user") { + + var token by mmkvString("") + + var localUser by mmkvParcelable() + var userOriginalJson by mmkvString("") +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt new file mode 100644 index 0000000..d871d8b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainActivity.kt @@ -0,0 +1,221 @@ +package com.remax.visualnovel.ui.main + +import android.os.Build +import androidx.activity.addCallback +import androidx.activity.viewModels +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.AppCompatImageView +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.alibaba.android.arouter.facade.annotation.Route +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.BaseBindingActivity +import com.remax.visualnovel.databinding.ActivityMainBinding +import com.remax.visualnovel.event.model.OnLoginEvent +import com.remax.visualnovel.event.model.tab.ContactTab +import com.remax.visualnovel.event.model.tab.MainTab +import com.remax.visualnovel.event.model.tab.OnTabChangedEvent +import com.remax.visualnovel.extension.launchWithRequest +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.Routers +import com.remax.visualnovel.utils.StatusBarUtils +import com.hjq.permissions.XXPermissions +import com.hjq.permissions.permission.PermissionLists +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUIEvents +import com.pengxr.modular.eventbus.generated.events.EventDefineOfUserEvents +import com.remax.visualnovel.ui.main.actor.ActorListFragment +import com.remax.visualnovel.ui.main.book.BookListFragment +import com.remax.visualnovel.ui.main.history.HistoryFragment +import com.remax.visualnovel.ui.main.managa.MangaListFragment +import dagger.hilt.android.AndroidEntryPoint + + + +@AndroidEntryPoint +@Route(path = Routers.MAIN) +class MainActivity : BaseBindingActivity() { + + private val mainViewModel by viewModels() + + override fun initView() { + StatusBarUtils.setStatusBarAndNavBarIsLight(this, false) + StatusBarUtils.setTransparent(this) + + onBackPressedDispatcher.addCallback(this) { + if (mainViewModel.canBack) { + finish() + } else { + toast(getString(R.string.close_app_hint)) + mainViewModel.startCanBackTimer() + } + } + + with(binding) { + with(viewPager2) { + setMargin(topMargin = StatusBarUtils.statusBarHeight) + + val fragments = listOf( + BookListFragment.newInstance(), + MangaListFragment.newInstance(), + ActorListFragment.newInstance(), + HistoryFragment.newInstance() + ) + + offscreenPageLimit = fragments.size + isUserInputEnabled = false + adapter = object : FragmentStateAdapter(this@MainActivity) { + override fun getItemCount() = fragments.size + override fun createFragment(position: Int): Fragment = fragments[position] + } + } + + changeViewPagerIndex(MainTab.TAB_BOOKS) + setOnClick(bookItem, mangaItem, actorItem, historyItem) { + when (this) { + bookItem -> changeViewPagerIndex(MainTab.TAB_BOOKS) + mangaItem -> changeViewPagerIndex(MainTab.TAB_MANGAS) + actorItem -> changeViewPagerIndex(MainTab.TAB_ACTORS) + historyItem -> changeViewPagerIndex(MainTab.TAB_HISTORY) + } + } + } + } + + override fun initData() { + refreshBaseInfo() + launchWithRequest({ + // TODO load init datas by model + //mainViewModel.getAllRole(true) + }) + loginNim() + //chkScheme(intent) + } + + override fun onDestroy() { + super.onDestroy() + EventDefineOfUserEvents.onLoginStatusChanged().removeObserver(loginObserver) + } + + private val loginObserver = androidx.lifecycle.Observer { + if (it?.isLogin() == true) { + loginNim() + checkRegister() + } + launchWithRequest({ + //mainViewModel.getAllRole(true) + }) + } + + override fun subscribeUi() { + + } + + + + /** + * 新建账号需要完善信息 + */ + private fun checkRegister() { + if (LoginManager.user?.cpUserInfo == true) { + Routers.navigation(Routers.REGISTER) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + XXPermissions.with(this).permission(PermissionLists.getPostNotificationsPermission()).request(null) + } + } + } + + private fun refreshBaseInfo() { + if (LoginManager.isLogin) { + launchWithRequest({ + mainViewModel.getMyBaseInfo() + }) + } + } + + /** + * 登录网易云信 + */ + private fun loginNim() { + /*launchAndCollect({ + mainViewModel.getNimInfo() + }) { + onSuccess = { + it?.run { + NimManager.login(this) + } + LoginManager.refreshUnreadCount() + } + } + launchWithRequest(mainViewModel::getNimInfo)*/ + } + + + + private fun changeViewPagerIndex(positionTab: MainTab) { + var go = positionTab.checkLogin + if (go) { + LoginManager.checkLogin { + go = false + } + } + if (go) return + + binding.run { + viewPager2.setCurrentItem(positionTab.index, false) + + val tabItemUIList = listOf( + Pair( + MainTab.TAB_BOOKS, + TabItemUI(bookItem, R.mipmap.main_tab_book_on, R.mipmap.main_tab_book_off) + ), + Pair( + MainTab.TAB_MANGAS, + TabItemUI(mangaItem, R.mipmap.main_tab_manga_on, R.mipmap.main_tab_manga_off) + ), + Pair( + MainTab.TAB_ACTORS, + TabItemUI(actorItem, R.mipmap.main_tab_actor_on, R.mipmap.main_tab_actor_off) + ), + Pair(MainTab.TAB_HISTORY, + TabItemUI(historyItem, R.mipmap.main_tab_history_on, R.mipmap.main_tab_history_off)) + ) + + tabItemUIList.forEach { + if (it.first.index == positionTab.index) { + it.second.item.setImageResource(it.second.selectRes) + } else { + it.second.item.setImageResource(it.second.unselectRes) + } + } + } + + + } + + companion object { + fun start(jumpItem: MainTab = MainTab.TAB_BOOKS, chatItem: ContactTab? = null) { + fun go() { + Routers.navigation(Routers.MAIN) + EventDefineOfUIEvents.onHomeTabChanged().post(OnTabChangedEvent(jumpItem, chatItem)) + } + // chat和me的tab需要检查登录权限 + if (jumpItem.checkLogin) { + LoginManager.checkLogin { + go() + } + } else { + go() + } + } + } + + data class TabItemUI( + val item: AppCompatImageView, + @DrawableRes val selectRes: Int, + @DrawableRes val unselectRes: Int, + ) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt new file mode 100644 index 0000000..0b98661 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/MainViewModel.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.ui.main + +/** + * Created by HJW on 2025/7/14 + */ +import androidx.lifecycle.viewModelScope +import com.remax.visualnovel.app.viewmodel.base.UserViewModel +import com.remax.visualnovel.entity.response.AIDict +import com.remax.visualnovel.repository.api.DictRepository +import com.remax.visualnovel.utils.TimeUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor(private val dictRepository: DictRepository) : UserViewModel() { + + var aiDict: AIDict? = null + private set + + private val _aiDictFlow = MutableSharedFlow() + val aiDictFlow = _aiDictFlow.asSharedFlow() + + /*suspend fun getAllRole(sendFlow: Boolean = false) = dictRepository.getAIDict().transformResult({ + aiDict = it + if (sendFlow) { + _aiDictFlow.emit(true) + } + })*/ + + var canBack = false + fun startCanBackTimer() { + viewModelScope.launch { + canBack = true + delay(TimeUtils.ONE_SECOND * 3) + canBack = false + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt new file mode 100644 index 0000000..443f6d0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListFragment.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.ui.main.book + +import android.os.Bundle +import androidx.fragment.app.viewModels +import com.alibaba.android.arouter.facade.annotation.Route +import com.alibaba.android.arouter.launcher.ARouter +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.app.base.BaseBindingFragment +import com.remax.visualnovel.databinding.FragmentMainBookBinding +import com.remax.visualnovel.utils.Routers +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue + + + +@AndroidEntryPoint +@Route(path = Routers.ROUTE_FRAG_BOOKLIST) +class BookListFragment : BaseBindingFragment() { + + private val contactViewModel by viewModels() + + override fun onCreated(bundle: Bundle?) { + setUI() + } + + override fun backgroundColorType(): BgColorType { + return BgColorType.TRANSPARENT + } + + private fun setUI() { + with(binding) { + + } + } + + companion object { + fun newInstance(): BookListFragment { + return ARouter.getInstance().build(Routers.ROUTE_FRAG_BOOKLIST) + .navigation() as BookListFragment + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt new file mode 100644 index 0000000..961f7ff --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/book/BookListViewModel.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.ui.main.book + + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.app.viewmodel.base.BaseViewModel +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.BooksRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + + + +@HiltViewModel +class BookListViewModel @Inject constructor(private val chatRepository: BooksRepository) : BaseViewModel() { + + private val _msgStatFlow = MutableSharedFlow>() + val msgStatFlow = _msgStatFlow.asSharedFlow() + + suspend fun getMessageStat() { + _msgStatFlow.emit(chatRepository.getBooks()) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt new file mode 100644 index 0000000..408877c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryFragment.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.ui.main.history + +import android.os.Bundle +import androidx.fragment.app.viewModels +import com.alibaba.android.arouter.facade.annotation.Route +import com.alibaba.android.arouter.launcher.ARouter +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.app.base.BaseBindingFragment +import com.remax.visualnovel.databinding.FragmentMainHistoryBinding +import com.remax.visualnovel.utils.Routers +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue + + + +@AndroidEntryPoint +@Route(path = Routers.ROUTE_FRAG_HISTORY) +class HistoryFragment : BaseBindingFragment() { + + private val contactViewModel by viewModels() + + override fun onCreated(bundle: Bundle?) { + setUI() + } + + override fun backgroundColorType(): BgColorType { + return BgColorType.TRANSPARENT + } + + private fun setUI() { + with(binding) { + + } + } + + companion object { + fun newInstance(): HistoryFragment { + return ARouter.getInstance().build(Routers.ROUTE_FRAG_HISTORY) + .navigation() as HistoryFragment + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt new file mode 100644 index 0000000..bb7c9d5 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/history/HistoryViewModel.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.ui.main.history + + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.app.viewmodel.base.BaseViewModel +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.BooksRepository +import com.remax.visualnovel.repository.api.HistoryRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + + + +@HiltViewModel +class HistoryViewModel @Inject constructor(private val chatRepository: HistoryRepository) : BaseViewModel() { + + private val _msgStatFlow = MutableSharedFlow>() + val msgStatFlow = _msgStatFlow.asSharedFlow() + + suspend fun getMessageStat() { + _msgStatFlow.emit(chatRepository.getBooks()) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt new file mode 100644 index 0000000..52be620 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListFragment.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.ui.main.managa + +import android.os.Bundle +import androidx.fragment.app.viewModels +import com.alibaba.android.arouter.facade.annotation.Route +import com.alibaba.android.arouter.launcher.ARouter +import com.dylanc.loadingstateview.BgColorType +import com.remax.visualnovel.app.base.BaseBindingFragment +import com.remax.visualnovel.databinding.FragmentMainMangaBinding +import com.remax.visualnovel.utils.Routers +import dagger.hilt.android.AndroidEntryPoint +import kotlin.getValue + + + +@AndroidEntryPoint +@Route(path = Routers.ROUTE_FRAG_MANGALIST) +class MangaListFragment : BaseBindingFragment() { + + private val contactViewModel by viewModels() + + override fun onCreated(bundle: Bundle?) { + setUI() + } + + override fun backgroundColorType(): BgColorType { + return BgColorType.TRANSPARENT + } + + private fun setUI() { + with(binding) { + + } + } + + companion object { + fun newInstance(): MangaListFragment { + return ARouter.getInstance().build(Routers.ROUTE_FRAG_MANGALIST) + .navigation() as MangaListFragment + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt new file mode 100644 index 0000000..f6d9cbb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/main/managa/MangaListViewModel.kt @@ -0,0 +1,25 @@ +package com.remax.visualnovel.ui.main.managa + + +import com.remax.visualnovel.entity.response.Book +import com.remax.visualnovel.app.viewmodel.base.BaseViewModel +import com.remax.visualnovel.entity.response.base.Response +import com.remax.visualnovel.repository.api.ManagasRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + + + +@HiltViewModel +class MangaListViewModel @Inject constructor(private val chatRepository: ManagasRepository) : BaseViewModel() { + + private val _msgStatFlow = MutableSharedFlow>() + val msgStatFlow = _msgStatFlow.asSharedFlow() + + suspend fun getMessageStat() { + _msgStatFlow.emit(chatRepository.getBooks()) + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt new file mode 100644 index 0000000..009d941 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/AppUtils.kt @@ -0,0 +1,23 @@ +package com.remax.visualnovel.utils + +import android.annotation.SuppressLint +import android.provider.Settings +import com.remax.visualnovel.app.base.app.CommonApplicationProxy + +/** + * Created by HJW on 2023/5/10 + */ +class AppUtils { + + companion object { + private var androidId = "" + + @SuppressLint("HardwareIds") + fun getAndroidID() = androidId.ifEmpty { + val id = Settings.Secure.getString(CommonApplicationProxy.application.contentResolver, Settings.Secure.ANDROID_ID) + androidId = id + if (id.isNullOrEmpty()) "" else id + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt new file mode 100644 index 0000000..85beb8f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/EpalUtils.kt @@ -0,0 +1,137 @@ +package com.remax.visualnovel.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.util.regex.Matcher +import java.util.regex.Pattern + +class EpalUtils { + + companion object { + /** + * 自动适配单位 + * + * @param size + * @return + */ + fun formatNumberAutoSize(size: Long, pattern: String = "####.0"): String { + if (size < 1000) { + return size.toString() + } + val format = DecimalFormat(pattern) + format.groupingSize = 0 + format.roundingMode = RoundingMode.FLOOR + format.maximumFractionDigits = 1 + if (size < 1000000) { + val bsize = size.toDouble() + val ksize = bsize / 1000 + var str = format.format(ksize) + str += "K" + return str + } else if (size < 1000000000) { + val bsize = size.toDouble() + val ksize = bsize / 1000000 + var str = format.format(ksize) + str += "M" + return str + } else { + val bsize = size.toDouble() + val ksize = bsize / 1000000000 + var str = format.format(ksize) + str += "B" + return str + } + } + + fun parseEditBuff(amount: String): Long { + runCatching { + amount.replace(",", ".").toDouble() + }.onSuccess { + val b1 = BigDecimal(it.toString()) + val b2 = BigDecimal("100.0") + return (b1.multiply(b2).toDouble()).toLong() + }.onFailure { + return 0 + } + return 0 + } + + /** + * 自动适配单位 + * + * @param size + * @return + */ + fun formatNumberAutoSize(size: Int?): String { + return formatNumberAutoSize((size?:0).toLong()) + } + + fun getRandom(): String { + return System.currentTimeMillis().toString() + List(3) { ('a'..'z').random() }.joinToString("") + } + + /** + * 去掉字符串中的前后空格符、换行符 + */ + fun replaceBlank(str: String?): String { + var dest = "" + if (!str.isNullOrBlank()) { + dest = str.trim() + val p: Pattern = Pattern.compile("\t|\r|\n") + val m: Matcher = p.matcher(dest) + dest = m.replaceAll("") + } + return dest + } + + /** + * 去掉字符串左右换行 + * + * @param str 原字符串 + * @return 转换后的字符串 + */ + fun trimN(str: String): String { + var len = str.length + var st = 0 + val text = str.toCharArray() + while (st < len && text[st] <= '\r') { + st++ + } + while (st < len && text[len - 1] <= '\r') { + len-- + } + return if (st > 0 || len < str.length) str.substring(st, len) else str + } + + fun viewToBitmap(view: View): Bitmap? { + var width = view.width + var height = view.height + view.clearFocus() + if (width <= 0 || height <= 0) { + val specSize = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + view.measure(specSize, specSize) + width = view.measuredWidth + height = view.measuredHeight + } + + if (width <= 0 || height <= 0) { + return null + } + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + if (view.right <= 0 || view.bottom <= 0) { + view.layout(0, 0, width, height) + view.draw(canvas) + } else { + view.layout(view.left, view.top, view.right, view.bottom) + view.draw(canvas) + } + return bitmap + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java new file mode 100644 index 0000000..8d6adc0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/KeyboardUtils.java @@ -0,0 +1,385 @@ +package com.remax.visualnovel.utils; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.os.SystemClock; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import java.lang.reflect.Field; +import java.util.Timer; +import java.util.TimerTask; + +/** + * + */ +public final class KeyboardUtils { + + private static final int TAG_ON_GLOBAL_LAYOUT_LISTENER = -8; + + private KeyboardUtils() { + throw new UnsupportedOperationException("u can't instantiate me..."); + } + + /** + * Show the soft input. + */ + public static void showSoftInput() { + InputMethodManager imm = (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) { + return; + } + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + /** + * Show the soft input. + */ + public static void showSoftInput(@NonNull Activity activity) { + if (!isSoftInputVisible(activity)) { + toggleSoftInput(); + } + } + + /** + * Show the soft input. + * + * @param view The view. + */ +// public static void showSoftInput(@NonNull final View view) { +// showSoftInput(view, 0); +// } + + /** + * Show the soft input. + * + * @param view The view. + */ + public static void showSoftInput(@NonNull final View view) { + // 弹出软键盘,并且让EditText获取可输入的焦点 + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.requestFocus(); + // 在异步去执行键盘的展开 + Timer timer = new Timer(); + timer.schedule(new TimerTask() { + public void run() { + InputMethodManager inputManager = (InputMethodManager) view + .getContext().getSystemService( + Context.INPUT_METHOD_SERVICE); + if (null != inputManager) { + inputManager.showSoftInput(view, 0); + } + } + }, 300); + } + + + public static void showSoftInput(@NonNull final View view, final int flags) { + InputMethodManager imm = + (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + view.setFocusable(true); + view.setFocusableInTouchMode(true); + view.requestFocus(); + imm.showSoftInput(view, flags); +// imm.showSoftInput(view, flags, new ResultReceiver(new Handler()) { +// @Override +// protected void onReceiveResult(int resultCode, Bundle resultData) { +// if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN +// || resultCode == InputMethodManager.RESULT_HIDDEN) { +// toggleSoftInput(); +// } +// } +// }); +// imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + + /** + * Hide the soft input. + * + * @param activity The activity. + */ + public static void hideSoftInput(@NonNull final Activity activity) { + hideSoftInput(activity.getWindow()); + } + + /** + * Hide the soft input. + * + * @param window The window. + */ + public static void hideSoftInput(@NonNull final Window window) { + View view = window.getCurrentFocus(); + if (view == null) { + View decorView = window.getDecorView(); + View focusView = decorView.findViewWithTag("keyboardTagView"); + if (focusView == null) { + view = new EditText(window.getContext()); + view.setTag("keyboardTagView"); + ((ViewGroup) decorView).addView(view, 0, 0); + } else { + view = focusView; + } + view.requestFocus(); + } + hideSoftInput(view); + } + + /** + * Hide the soft input. + * + * @param view The view. + */ + public static void hideSoftInput(@NonNull final View view) { + InputMethodManager imm = + (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + + private static long millis; + + /** + * Hide the soft input. + * + * @param activity The activity. + */ + public static void hideSoftInputByToggle(final Activity activity) { + long nowMillis = SystemClock.uptimeMillis(); + long delta = nowMillis - millis; + if (Math.abs(delta) > 500 && KeyboardUtils.isSoftInputVisible(activity)) { + KeyboardUtils.toggleSoftInput(); + } + millis = nowMillis; + } + + /** + * Toggle the soft input display or not. + */ + public static void toggleSoftInput() { + if (CommonApplicationProxy.INSTANCE.getApplication() != null) { + InputMethodManager imm = + (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + imm.toggleSoftInput(0, 0); + } + } + + private static int sDecorViewDelta = 0; + + /** + * Return whether soft input is visible. + * + * @param activity The activity. + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isSoftInputVisible(@NonNull final Activity activity) { + return getDecorViewInvisibleHeight(activity.getWindow()) > 0; + } + + private static int getDecorViewInvisibleHeight(@NonNull final Window window) { + final View decorView = window.getDecorView(); + final Rect outRect = new Rect(); + decorView.getWindowVisibleDisplayFrame(outRect); + int delta = Math.abs(decorView.getBottom() - outRect.bottom); + if (delta <= StatusBarUtils.INSTANCE.getStatusBarHeight() + StatusBarUtils.INSTANCE.getNavBarHeight(false)) { + sDecorViewDelta = delta; + return 0; + } + return delta - sDecorViewDelta; + } + + /** + * Register soft input changed listener. + * + * @param activity The activity. + * @param listener The soft input changed listener. + */ + public static void registerSoftInputChangedListener(@NonNull final Activity activity, + @NonNull final OnSoftInputChangedListener listener) { + registerSoftInputChangedListener(activity.getWindow(), listener); + } + + /** + * Register soft input changed listener. + * + * @param window The window. + * @param listener The soft input changed listener. + */ + public static void registerSoftInputChangedListener(@NonNull final Window window, + @NonNull final OnSoftInputChangedListener listener) { + final int flags = window.getAttributes().flags; + if ((flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0) { + window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); + } + final FrameLayout contentView = window.findViewById(android.R.id.content); + final int[] decorViewInvisibleHeightPre = {getDecorViewInvisibleHeight(window)}; + OnGlobalLayoutListener onGlobalLayoutListener = new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int height = getDecorViewInvisibleHeight(window); + if (decorViewInvisibleHeightPre[0] != height) { + listener.onSoftInputChanged(height); + decorViewInvisibleHeightPre[0] = height; + } + } + }; + contentView.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener); + contentView.setTag(TAG_ON_GLOBAL_LAYOUT_LISTENER, onGlobalLayoutListener); + } + + /** + * Unregister soft input changed listener. + * + * @param window The window. + */ + public static void unregisterSoftInputChangedListener(@NonNull final Window window) { + final View contentView = window.findViewById(android.R.id.content); + if (contentView == null) return; + Object tag = contentView.getTag(TAG_ON_GLOBAL_LAYOUT_LISTENER); + if (tag instanceof OnGlobalLayoutListener) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + contentView.getViewTreeObserver().removeOnGlobalLayoutListener((OnGlobalLayoutListener) tag); + } + } + } + + /** + * Fix the bug of 5497 in Android. + *

Don't set adjustResize

+ * + * @param activity The activity. + */ + public static void fixAndroidBug5497(@NonNull final Activity activity) { + fixAndroidBug5497(activity.getWindow()); + } + + /** + * Fix the bug of 5497 in Android. + *

It will clean the adjustResize

+ * + * @param window The window. + */ + public static void fixAndroidBug5497(@NonNull final Window window) { + int softInputMode = window.getAttributes().softInputMode; + window.setSoftInputMode(softInputMode & ~WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + final FrameLayout contentView = window.findViewById(android.R.id.content); + final View contentViewChild = contentView.getChildAt(0); + final int paddingBottom = contentViewChild.getPaddingBottom(); + final int[] contentViewInvisibleHeightPre5497 = {getContentViewInvisibleHeight(window)}; + contentView.getViewTreeObserver() + .addOnGlobalLayoutListener(new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int height = getContentViewInvisibleHeight(window); + if (contentViewInvisibleHeightPre5497[0] != height) { + contentViewChild.setPadding( + contentViewChild.getPaddingLeft(), + contentViewChild.getPaddingTop(), + contentViewChild.getPaddingRight(), + paddingBottom + getDecorViewInvisibleHeight(window) + ); + contentViewInvisibleHeightPre5497[0] = height; + } + } + }); + } + + private static int getContentViewInvisibleHeight(final Window window) { + final View contentView = window.findViewById(android.R.id.content); + if (contentView == null) return 0; + final Rect outRect = new Rect(); + contentView.getWindowVisibleDisplayFrame(outRect); + int delta = Math.abs(contentView.getBottom() - outRect.bottom); + if (delta <= StatusBarUtils.INSTANCE.getStatusBarHeight() + StatusBarUtils.INSTANCE.getNavBarHeight(false)) { + return 0; + } + return delta; + } + + /** + * Fix the leaks of soft input. + * + * @param activity The activity. + */ + public static void fixSoftInputLeaks(@NonNull final Activity activity) { + fixSoftInputLeaks(activity.getWindow()); + } + + /** + * Fix the leaks of soft input. + * + * @param window The window. + */ + public static void fixSoftInputLeaks(@NonNull final Window window) { + InputMethodManager imm = + (InputMethodManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) return; + String[] leakViews = new String[]{"mLastSrvView", "mCurRootView", "mServedView", "mNextServedView"}; + for (String leakView : leakViews) { + try { + Field leakViewField = InputMethodManager.class.getDeclaredField(leakView); + if (!leakViewField.isAccessible()) { + leakViewField.setAccessible(true); + } + Object obj = leakViewField.get(imm); + if (!(obj instanceof View)) continue; + View view = (View) obj; + if (view.getRootView() == window.getDecorView().getRootView()) { + leakViewField.set(imm, null); + } + } catch (Throwable ignore) {/**/} + } + } + + /** + * Click blank area to hide soft input. + *

Copy the following code in ur activity.

+ */ + public static void clickBlankArea2HideSoftInput() { + /* + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + View v = getCurrentFocus(); + if (isShouldHideKeyboard(v, ev)) { + KeyboardUtils.hideSoftInput(this); + } + } + return super.dispatchTouchEvent(ev); + } + + // Return whether touch the view. + private boolean isShouldHideKeyboard(View v, MotionEvent event) { + if ((v instanceof EditText)) { + int[] l = {0, 0}; + v.getLocationOnScreen(l); + int left = l[0], + top = l[1], + bottom = top + v.getHeight(), + right = left + v.getWidth(); + return !(event.getRawX() > left && event.getRawX() < right + && event.getRawY() > top && event.getRawY() < bottom); + } + return false; + } + */ + } + + /////////////////////////////////////////////////////////////////////////// + // interface + /////////////////////////////////////////////////////////////////////////// + public interface OnSoftInputChangedListener { + void onSoftInputChanged(int height); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt new file mode 100644 index 0000000..a0b0dce --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/NotLoggingTree.kt @@ -0,0 +1,14 @@ +package com.remax.visualnovel.utils + +import timber.log.Timber + +/** + * Created by HJW on 2024/4/1 + */ +class NotLoggingTree : Timber.Tree(){ + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt new file mode 100644 index 0000000..c3ed97c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/Routers.kt @@ -0,0 +1,54 @@ +package com.remax.visualnovel.utils + +import com.alibaba.android.arouter.launcher.ARouter +import com.remax.visualnovel.BuildConfig + +/** + * 路由配置; + * Created by Eric on 2020/9/1 + */ +class Routers { + + companion object { + + private const val ROUTER = "/router/" + + /** + * 欢迎页 + */ + const val WELCOME = "${ROUTER}welcome" + const val TEST = "${ROUTER}test" + + /** + * main activity + */ + const val MAIN = "${ROUTER}main" + const val ROUTE_FRAG_BOOKLIST = "${ROUTER}bookList" + const val ROUTE_FRAG_MANGALIST = "${ROUTER}mangaList" + const val ROUTE_FRAG_ACTORLIST = "${ROUTER}actorList" + const val ROUTE_FRAG_HISTORY = "${ROUTER}history" + const val LOGIN = "${ROUTER}login" + const val REGISTER = "${ROUTER}register" + const val BROWSER = "${ROUTER}browser" + + + + fun navigation(path: String) { + ARouter.getInstance().build(path).navigation() + } + + fun navigationToUA() { + navigationToBrowser(url = BuildConfig.EPAL_TERMS_SERVICES) + } + + fun navigationToBrowser(title: String = "", url: String, needTitle: Boolean = true) { + ARouter.getInstance() + .build(BROWSER) + .withString("title", title) + .withBoolean("needTitle", needTitle) + .withString("url", url) + .navigation() + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java new file mode 100644 index 0000000..79ca166 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/SpanUtils.java @@ -0,0 +1,1476 @@ +package com.remax.visualnovel.utils; + +import static android.graphics.BlurMaskFilter.Blur; + +import android.annotation.SuppressLint; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BlurMaskFilter; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.text.Layout; +import android.text.Layout.Alignment; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.method.LinkMovementMethod; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.AlignmentSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.LeadingMarginSpan; +import android.text.style.LineHeightSpan; +import android.text.style.MaskFilterSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.ReplacementSpan; +import android.text.style.ScaleXSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.SubscriptSpan; +import android.text.style.SuperscriptSpan; +import android.text.style.TypefaceSpan; +import android.text.style.URLSpan; +import android.text.style.UnderlineSpan; +import android.text.style.UpdateAppearance; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.FloatRange; +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.drake.spannable.span.CenterImageSpan; + +import java.io.InputStream; +import java.io.Serializable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; + +import timber.log.Timber; + +/** + * 富文本类 + */ +public final class SpanUtils { + + private static final int COLOR_DEFAULT = 0xFEFFFFFF; + + public static final int ALIGN_BOTTOM = 0; + public static final int ALIGN_BASELINE = 1; + public static final int ALIGN_CENTER = 2; + public static final int ALIGN_TOP = 3; + + @IntDef({ALIGN_BOTTOM, ALIGN_BASELINE, ALIGN_CENTER, ALIGN_TOP}) + @Retention(RetentionPolicy.SOURCE) + public @interface Align { + } + + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + + public static SpanUtils with(final TextView textView) { + return new SpanUtils(textView); + } + + private TextView mTextView; + private CharSequence mText; + private int flag; + private int foregroundColor; + private int backgroundColor; + private int lineHeight; + private int alignLine; + private int quoteColor; + private int stripeWidth; + private int quoteGapWidth; + private int first; + private int rest; + private int bulletColor; + private int bulletRadius; + private int bulletGapWidth; + private int fontSize; + private boolean fontSizeIsDp; + private float proportion; + private float xProportion; + private boolean isStrikethrough; + private boolean isUnderline; + private boolean isSuperscript; + private boolean isSubscript; + private boolean isBold; + private boolean isItalic; + private boolean isBoldItalic; + private String fontFamily; + private Typeface typeface; + private Alignment alignment; + private int verticalAlign; + private ClickableSpan clickSpan; + private String url; + private float blurRadius; + private Blur style; + private Shader shader; + private float shadowRadius; + private float shadowDx; + private float shadowDy; + private int shadowColor; + private Object[] spans; + + private Bitmap imageBitmap; + private Drawable imageDrawable; + private Uri imageUri; + private int imageResourceId; + private int alignImage; + + private int spaceSize; + private int spaceColor; + + private SerializableSpannableStringBuilder mBuilder; + private boolean isCreated; + + private int mType; + private final int mTypeCharSequence = 0; + private final int mTypeImage = 1; + private final int mTypeSpace = 2; + + private SpanUtils(TextView textView) { + this(); + mTextView = textView; + } + + public SpanUtils() { + mBuilder = new SerializableSpannableStringBuilder(); + mText = ""; + mType = -1; + setDefault(); + } + + private void setDefault() { + flag = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; + foregroundColor = COLOR_DEFAULT; + backgroundColor = COLOR_DEFAULT; + lineHeight = -1; + quoteColor = COLOR_DEFAULT; + first = -1; + bulletColor = COLOR_DEFAULT; + fontSize = -1; + proportion = -1; + xProportion = -1; + isStrikethrough = false; + isUnderline = false; + isSuperscript = false; + isSubscript = false; + isBold = false; + isItalic = false; + isBoldItalic = false; + fontFamily = null; + typeface = null; + alignment = null; + verticalAlign = -1; + clickSpan = null; + url = null; + blurRadius = -1; + shader = null; + shadowRadius = -1; + spans = null; + + imageBitmap = null; + imageDrawable = null; + imageUri = null; + imageResourceId = -1; + + spaceSize = -1; + } + + /** + * Set the span of flag. + * + * @param flag The flag. + *
    + *
  • {@link Spanned#SPAN_INCLUSIVE_EXCLUSIVE}
  • + *
  • {@link Spanned#SPAN_INCLUSIVE_INCLUSIVE}
  • + *
  • {@link Spanned#SPAN_EXCLUSIVE_EXCLUSIVE}
  • + *
  • {@link Spanned#SPAN_EXCLUSIVE_INCLUSIVE}
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFlag(final int flag) { + this.flag = flag; + return this; + } + + /** + * Set the span of foreground's color. + * + * @param color The color of foreground + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setForegroundColor(@ColorInt final int color) { + this.foregroundColor = color; + return this; + } + + /** + * Set the span of background's color. + * + * @param color The color of background + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBackgroundColor(@ColorInt final int color) { + this.backgroundColor = color; + return this; + } + + /** + * Set the span of line height. + * + * @param lineHeight The line height, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setLineHeight(@IntRange(from = 0) final int lineHeight) { + return setLineHeight(lineHeight, ALIGN_CENTER); + } + + /** + * Set the span of line height. + * + * @param lineHeight The line height, in pixel. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER}
  • + *
  • {@link Align#ALIGN_BOTTOM}
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setLineHeight(@IntRange(from = 0) final int lineHeight, + @Align final int align) { + this.lineHeight = lineHeight; + this.alignLine = align; + return this; + } + + /** + * Set the span of quote's color. + * + * @param color The color of quote + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setQuoteColor(@ColorInt final int color) { + return setQuoteColor(color, 2, 2); + } + + /** + * Set the span of quote's color. + * + * @param color The color of quote. + * @param stripeWidth The width of stripe, in pixel. + * @param gapWidth The width of gap, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setQuoteColor(@ColorInt final int color, + @IntRange(from = 1) final int stripeWidth, + @IntRange(from = 0) final int gapWidth) { + this.quoteColor = color; + this.stripeWidth = stripeWidth; + this.quoteGapWidth = gapWidth; + return this; + } + + /** + * Set the span of leading margin. + * + * @param first The indent for the first line of the paragraph. + * @param rest The indent for the remaining lines of the paragraph. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setLeadingMargin(@IntRange(from = 0) final int first, + @IntRange(from = 0) final int rest) { + this.first = first; + this.rest = rest; + return this; + } + + /** + * Set the span of bullet. + * + * @param gapWidth The width of gap, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBullet(@IntRange(from = 0) final int gapWidth) { + return setBullet(0, 3, gapWidth); + } + + /** + * Set the span of bullet. + * + * @param color The color of bullet. + * @param radius The radius of bullet, in pixel. + * @param gapWidth The width of gap, in pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBullet(@ColorInt final int color, + @IntRange(from = 0) final int radius, + @IntRange(from = 0) final int gapWidth) { + this.bulletColor = color; + this.bulletRadius = radius; + this.bulletGapWidth = gapWidth; + return this; + } + + /** + * Set the span of font's size. + * + * @param size The size of font. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontSize(@IntRange(from = 0) final int size) { + return setFontSize(size, false); + } + + /** + * Set the span of size of font. + * + * @param size The size of font. + * @param isSp True to use sp, false to use pixel. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontSize(@IntRange(from = 0) final int size, final boolean isSp) { + this.fontSize = size; + this.fontSizeIsDp = isSp; + return this; + } + + /** + * Set the span of proportion of font. + * + * @param proportion The proportion of font. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontProportion(final float proportion) { + this.proportion = proportion; + return this; + } + + /** + * Set the span of transverse proportion of font. + * + * @param proportion The transverse proportion of font. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontXProportion(final float proportion) { + this.xProportion = proportion; + return this; + } + + /** + * Set the span of strikethrough. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setStrikethrough() { + this.isStrikethrough = true; + return this; + } + + /** + * Set the span of underline. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setUnderline() { + this.isUnderline = true; + return this; + } + + /** + * Set the span of superscript. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setSuperscript() { + this.isSuperscript = true; + return this; + } + + /** + * Set the span of subscript. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setSubscript() { + this.isSubscript = true; + return this; + } + + /** + * Set the span of bold. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBold() { + isBold = true; + return this; + } + + /** + * Set the span of italic. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setItalic() { + isItalic = true; + return this; + } + + /** + * Set the span of bold italic. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBoldItalic() { + isBoldItalic = true; + return this; + } + + /** + * Set the span of font family. + * + * @param fontFamily The font family. + *
    + *
  • monospace
  • + *
  • serif
  • + *
  • sans-serif
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setFontFamily(@NonNull final String fontFamily) { + this.fontFamily = fontFamily; + return this; + } + + /** + * Set the span of typeface. + * + * @param typeface The typeface. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setTypeface(@NonNull final Typeface typeface) { + this.typeface = typeface; + return this; + } + + /** + * Set the span of horizontal alignment. + * + * @param alignment The alignment. + *
    + *
  • {@link Alignment#ALIGN_NORMAL }
  • + *
  • {@link Alignment#ALIGN_OPPOSITE}
  • + *
  • {@link Alignment#ALIGN_CENTER }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setHorizontalAlign(@NonNull final Alignment alignment) { + this.alignment = alignment; + return this; + } + + /** + * Set the span of vertical alignment. + * + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setVerticalAlign(@Align final int align) { + this.verticalAlign = align; + return this; + } + + /** + * Set the span of click. + *

Must set {@code view.setMovementMethod(LinkMovementMethod.getInstance())}

+ * + * @param clickSpan The span of click. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setClickSpan(@NonNull final ClickableSpan clickSpan) { + if (mTextView != null && mTextView.getMovementMethod() == null) { + mTextView.setMovementMethod(LinkMovementMethod.getInstance()); + } + this.clickSpan = clickSpan; + return this; + } + + /** + * Set the span of click. + *

Must set {@code view.setMovementMethod(LinkMovementMethod.getInstance())}

+ * + * @param color The color of click span. + * @param underlineText True to support underline, false otherwise. + * @param listener The listener of click span. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setClickSpan(@ColorInt final int color, + final boolean underlineText, + final View.OnClickListener listener) { + if (mTextView != null && mTextView.getMovementMethod() == null) { + mTextView.setHighlightColor(Color.TRANSPARENT); + mTextView.setMovementMethod(LinkMovementMethod.getInstance()); + } + this.clickSpan = new ClickableSpan() { + + @Override + public void updateDrawState(@NonNull TextPaint paint) { + paint.setColor(color); + paint.setUnderlineText(underlineText); + } + + @Override + public void onClick(@NonNull View widget) { + if (listener != null) { + listener.onClick(widget); + } + } + }; + return this; + } + + /** + * Set the span of url. + *

Must set {@code view.setMovementMethod(LinkMovementMethod.getInstance())}

+ * + * @param url The url. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setUrl(@NonNull final String url) { + if (mTextView != null && mTextView.getMovementMethod() == null) { + mTextView.setMovementMethod(LinkMovementMethod.getInstance()); + } + this.url = url; + return this; + } + + /** + * Set the span of blur. + * + * @param radius The radius of blur. + * @param style The style. + *
    + *
  • {@link Blur#NORMAL}
  • + *
  • {@link Blur#SOLID}
  • + *
  • {@link Blur#OUTER}
  • + *
  • {@link Blur#INNER}
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils setBlur(@FloatRange(from = 0, fromInclusive = false) final float radius, + final Blur style) { + this.blurRadius = radius; + this.style = style; + return this; + } + + /** + * Set the span of shader. + * + * @param shader The shader. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setShader(@NonNull final Shader shader) { + this.shader = shader; + return this; + } + + /** + * Set the span of shadow. + * + * @param radius The radius of shadow. + * @param dx X-axis offset, in pixel. + * @param dy Y-axis offset, in pixel. + * @param shadowColor The color of shadow. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setShadow(@FloatRange(from = 0, fromInclusive = false) final float radius, + final float dx, + final float dy, + final int shadowColor) { + this.shadowRadius = radius; + this.shadowDx = dx; + this.shadowDy = dy; + this.shadowColor = shadowColor; + return this; + } + + + /** + * Set the spans. + * + * @param spans The spans. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils setSpans(@NonNull final Object... spans) { + if (spans.length > 0) { + this.spans = spans; + } + return this; + } + + /** + * Append the text text. + * + * @param text The text. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils append(@NonNull final CharSequence text) { + apply(mTypeCharSequence); + mText = text; + return this; + } + + /** + * Append one line. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendLine() { + apply(mTypeCharSequence); + mText = LINE_SEPARATOR; + return this; + } + + /** + * Append text and one line. + * + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendLine(@NonNull final CharSequence text) { + apply(mTypeCharSequence); + mText = text + LINE_SEPARATOR; + return this; + } + + /** + * Append one image. + * + * @param bitmap The bitmap of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Bitmap bitmap) { + return appendImage(bitmap, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param bitmap The bitmap. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Bitmap bitmap, @Align final int align) { + apply(mTypeImage); + this.imageBitmap = bitmap; + this.alignImage = align; + return this; + } + + /** + * Append one image. + * + * @param drawable The drawable of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Drawable drawable) { + return appendImage(drawable, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param drawable The drawable of image. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Drawable drawable, @Align final int align) { + apply(mTypeImage); + this.imageDrawable = drawable; + this.alignImage = align; + return this; + } + + /** + * Append one image. + * + * @param uri The uri of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Uri uri) { + return appendImage(uri, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param uri The uri of image. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@NonNull final Uri uri, @Align final int align) { + apply(mTypeImage); + this.imageUri = uri; + this.alignImage = align; + return this; + } + + /** + * Append one image. + * + * @param resourceId The resource id of image. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@DrawableRes final int resourceId) { + return appendImage(resourceId, ALIGN_BOTTOM); + } + + /** + * Append one image. + * + * @param resourceId The resource id of image. + * @param align The alignment. + *
    + *
  • {@link Align#ALIGN_TOP }
  • + *
  • {@link Align#ALIGN_CENTER }
  • + *
  • {@link Align#ALIGN_BASELINE}
  • + *
  • {@link Align#ALIGN_BOTTOM }
  • + *
+ * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendImage(@DrawableRes final int resourceId, @Align final int align) { + apply(mTypeImage); + this.imageResourceId = resourceId; + this.alignImage = align; + return this; + } + + /** + * Append space. + * + * @param size The size of space. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendSpace(@IntRange(from = 0) final int size) { + return appendSpace(size, Color.TRANSPARENT); + } + + /** + * Append space. + * + * @param size The size of space. + * @param color The color of space. + * @return the single {@link SpanUtils} instance + */ + public SpanUtils appendSpace(@IntRange(from = 0) final int size, @ColorInt final int color) { + apply(mTypeSpace); + spaceSize = size; + spaceColor = color; + return this; + } + + private void apply(final int type) { + applyLast(); + mType = type; + } + + public SpannableStringBuilder get() { + return mBuilder; + } + + /** + * Create the span string. + * + * @return the span string + */ + public SpannableStringBuilder create() { + applyLast(); + if (mTextView != null) { + mTextView.setText(mBuilder); + } + isCreated = true; + return mBuilder; + } + + private void applyLast() { + if (isCreated) { + return; + } + if (mType == mTypeCharSequence) { + updateCharCharSequence(); + } else if (mType == mTypeImage) { + updateImage(); + } else if (mType == mTypeSpace) { + updateSpace(); + } + setDefault(); + } + + private void updateCharCharSequence() { + if (mText.length() == 0) return; + int start = mBuilder.length(); + if (start == 0 && lineHeight != -1) {// bug of LineHeightSpan when first line + mBuilder.append(Character.toString((char) 2)) + .append("\n") + .setSpan(new AbsoluteSizeSpan(0), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + start = 2; + } + mBuilder.append(mText); + int end = mBuilder.length(); + if (verticalAlign != -1) { + mBuilder.setSpan(new VerticalAlignSpan(verticalAlign, foregroundColor), start, end, flag); + } + if (foregroundColor != COLOR_DEFAULT) { + mBuilder.setSpan(new ForegroundColorSpan(foregroundColor), start, end, flag); + } + if (backgroundColor != COLOR_DEFAULT) { + mBuilder.setSpan(new BackgroundColorSpan(backgroundColor), start, end, flag); + } + if (first != -1) { + mBuilder.setSpan(new LeadingMarginSpan.Standard(first, rest), start, end, flag); + } + if (quoteColor != COLOR_DEFAULT) { + mBuilder.setSpan( + new CustomQuoteSpan(quoteColor, stripeWidth, quoteGapWidth), + start, + end, + flag + ); + } + if (bulletColor != COLOR_DEFAULT) { + mBuilder.setSpan( + new CustomBulletSpan(bulletColor, bulletRadius, bulletGapWidth), + start, + end, + flag + ); + } + if (fontSize != -1) { + mBuilder.setSpan(new AbsoluteSizeSpan(fontSize, fontSizeIsDp), start, end, flag); + } + if (proportion != -1) { + mBuilder.setSpan(new RelativeSizeSpan(proportion), start, end, flag); + } + if (xProportion != -1) { + mBuilder.setSpan(new ScaleXSpan(xProportion), start, end, flag); + } + if (lineHeight != -1) { + mBuilder.setSpan(new CustomLineHeightSpan(lineHeight, alignLine), start, end, flag); + } + if (isStrikethrough) { + mBuilder.setSpan(new StrikethroughSpan(), start, end, flag); + } + if (isUnderline) { + mBuilder.setSpan(new UnderlineSpan(), start, end, flag); + } + if (isSuperscript) { + mBuilder.setSpan(new SuperscriptSpan(), start, end, flag); + } + if (isSubscript) { + mBuilder.setSpan(new SubscriptSpan(), start, end, flag); + } + if (isBold) { + mBuilder.setSpan(new StyleSpan(Typeface.BOLD), start, end, flag); + } + if (isItalic) { + mBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, end, flag); + } + if (isBoldItalic) { + mBuilder.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), start, end, flag); + } + if (fontFamily != null) { + mBuilder.setSpan(new TypefaceSpan(fontFamily), start, end, flag); + } + if (typeface != null) { + mBuilder.setSpan(new CustomTypefaceSpan(typeface), start, end, flag); + } + if (alignment != null) { + mBuilder.setSpan(new AlignmentSpan.Standard(alignment), start, end, flag); + } + if (clickSpan != null) { + mBuilder.setSpan(clickSpan, start, end, flag); + } + if (url != null) { + mBuilder.setSpan(new URLSpan(url), start, end, flag); + } + if (blurRadius != -1) { + mBuilder.setSpan( + new MaskFilterSpan(new BlurMaskFilter(blurRadius, style)), + start, + end, + flag + ); + } + if (shader != null) { + mBuilder.setSpan(new ShaderSpan(shader), start, end, flag); + } + if (shadowRadius != -1) { + mBuilder.setSpan( + new ShadowSpan(shadowRadius, shadowDx, shadowDy, shadowColor), + start, + end, + flag + ); + } + if (spans != null) { + for (Object span : spans) { + mBuilder.setSpan(span, start, end, flag); + } + } + } + + private void updateImage() { + int start = mBuilder.length(); + mText = ""; + updateCharCharSequence(); + int end = mBuilder.length(); + if (imageBitmap != null) { + if (alignImage == ALIGN_CENTER) { + mBuilder.setSpan(new CenterImageSpan(CommonApplicationProxy.INSTANCE.getApplication(), imageBitmap), start, end, flag); + } else { + mBuilder.setSpan(new CustomImageSpan(imageBitmap, alignImage), start, end, flag); + } + } else if (imageDrawable != null) { + if (alignImage == ALIGN_CENTER) { + mBuilder.setSpan(new CenterImageSpan(imageDrawable), start, end, flag); + } else { + mBuilder.setSpan(new CustomImageSpan(imageDrawable, alignImage), start, end, flag); + } + } else if (imageUri != null) { + mBuilder.setSpan(new CustomImageSpan(imageUri, alignImage), start, end, flag); + } else if (imageResourceId != -1) { + mBuilder.setSpan(new CustomImageSpan(imageResourceId, alignImage), start, end, flag); + } + } + + private void updateSpace() { + int start = mBuilder.length(); + mText = "< >"; + updateCharCharSequence(); + int end = mBuilder.length(); + mBuilder.setSpan(new SpaceSpan(spaceSize, spaceColor), start, end, flag); + } + + static class VerticalAlignSpan extends ReplacementSpan { + + static final int ALIGN_CENTER = 2; + static final int ALIGN_TOP = 3; + + private final int color; + + final int mVerticalAlignment; + + VerticalAlignSpan(int verticalAlignment, int color) { + this.color = color; + mVerticalAlignment = verticalAlignment; + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm) { + text = text.subSequence(start, end); + return (int) paint.measureText(text.toString()); + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + text = text.subSequence(start, end); + Paint.FontMetricsInt fm = paint.getFontMetricsInt(); +// int need = height - (v + fm.descent - fm.ascent - spanstartv); +// if (need > 0) { +// if (mVerticalAlignment == ALIGN_TOP) { +// fm.descent += need; +// } else if (mVerticalAlignment == ALIGN_CENTER) { +// fm.descent += need / 2; +// fm.ascent -= need / 2; +// } else { +// fm.ascent -= need; +// } +// } +// need = height - (v + fm.bottom - fm.top - spanstartv); +// if (need > 0) { +// if (mVerticalAlignment == ALIGN_TOP) { +// fm.bottom += need; +// } else if (mVerticalAlignment == ALIGN_CENTER) { +// fm.bottom += need / 2; +// fm.top -= need / 2; +// } else { +// fm.top -= need; +// } +// } + if (color != COLOR_DEFAULT) { + paint.setColor(color); + } + canvas.drawText(text.toString(), x, y - ((y + fm.descent + y + fm.ascent) / 2 - (bottom + top) / 2), paint); + } + } + + static class CustomLineHeightSpan implements LineHeightSpan { + + private final int height; + + static final int ALIGN_CENTER = 2; + static final int ALIGN_TOP = 3; + + final int mVerticalAlignment; + static Paint.FontMetricsInt sfm; + + CustomLineHeightSpan(int height, int verticalAlignment) { + this.height = height; + mVerticalAlignment = verticalAlignment; + } + + @Override + public void chooseHeight(final CharSequence text, final int start, final int end, + final int spanstartv, final int v, final Paint.FontMetricsInt fm) { +// LogUtils.e(fm, sfm); + if (sfm == null) { + sfm = new Paint.FontMetricsInt(); + sfm.top = fm.top; + sfm.ascent = fm.ascent; + sfm.descent = fm.descent; + sfm.bottom = fm.bottom; + sfm.leading = fm.leading; + } else { + fm.top = sfm.top; + fm.ascent = sfm.ascent; + fm.descent = sfm.descent; + fm.bottom = sfm.bottom; + fm.leading = sfm.leading; + } + int need = height - (v + fm.descent - fm.ascent - spanstartv); + if (need > 0) { + if (mVerticalAlignment == ALIGN_TOP) { + fm.descent += need; + } else if (mVerticalAlignment == ALIGN_CENTER) { + fm.descent += need / 2; + fm.ascent -= need / 2; + } else { + fm.ascent -= need; + } + } + need = height - (v + fm.bottom - fm.top - spanstartv); + if (need > 0) { + if (mVerticalAlignment == ALIGN_TOP) { + fm.bottom += need; + } else if (mVerticalAlignment == ALIGN_CENTER) { + fm.bottom += need / 2; + fm.top -= need / 2; + } else { + fm.top -= need; + } + } + if (end == ((Spanned) text).getSpanEnd(this)) { + sfm = null; + } +// LogUtils.e(fm, sfm); + } + } + + static class SpaceSpan extends ReplacementSpan { + + private final int width; + private final Paint paint = new Paint(); + + private SpaceSpan(final int width) { + this(width, Color.TRANSPARENT); + } + + private SpaceSpan(final int width, final int color) { + super(); + this.width = width; + paint.setColor(color); + paint.setStyle(Paint.Style.FILL); + } + + @Override + public int getSize(@NonNull final Paint paint, final CharSequence text, + @IntRange(from = 0) final int start, + @IntRange(from = 0) final int end, + @Nullable final Paint.FontMetricsInt fm) { + return width; + } + + @Override + public void draw(@NonNull final Canvas canvas, final CharSequence text, + @IntRange(from = 0) final int start, + @IntRange(from = 0) final int end, + final float x, final int top, final int y, final int bottom, + @NonNull final Paint paint) { + canvas.drawRect(x, top, x + width, bottom, this.paint); + } + } + + static class CustomQuoteSpan implements LeadingMarginSpan { + + private final int color; + private final int stripeWidth; + private final int gapWidth; + + private CustomQuoteSpan(final int color, final int stripeWidth, final int gapWidth) { + super(); + this.color = color; + this.stripeWidth = stripeWidth; + this.gapWidth = gapWidth; + } + + public int getLeadingMargin(final boolean first) { + return stripeWidth + gapWidth; + } + + public void drawLeadingMargin(final Canvas c, final Paint p, final int x, final int dir, + final int top, final int baseline, final int bottom, + final CharSequence text, final int start, final int end, + final boolean first, final Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + + p.setStyle(Paint.Style.FILL); + p.setColor(this.color); + + c.drawRect(x, top, x + dir * stripeWidth, bottom, p); + + p.setStyle(style); + p.setColor(color); + } + } + + static class CustomBulletSpan implements LeadingMarginSpan { + + private final int color; + private final int radius; + private final int gapWidth; + + private Path sBulletPath = null; + + private CustomBulletSpan(final int color, final int radius, final int gapWidth) { + this.color = color; + this.radius = radius; + this.gapWidth = gapWidth; + } + + public int getLeadingMargin(final boolean first) { + return 2 * radius + gapWidth; + } + + public void drawLeadingMargin(final Canvas c, final Paint p, final int x, final int dir, + final int top, final int baseline, final int bottom, + final CharSequence text, final int start, final int end, + final boolean first, final Layout l) { + if (((Spanned) text).getSpanStart(this) == start) { + Paint.Style style = p.getStyle(); + int oldColor = 0; + oldColor = p.getColor(); + p.setColor(color); + p.setStyle(Paint.Style.FILL); + if (c.isHardwareAccelerated()) { + if (sBulletPath == null) { + sBulletPath = new Path(); + // Bullet is slightly better to avoid aliasing artifacts on mdpi devices. + sBulletPath.addCircle(0.0f, 0.0f, radius, Path.Direction.CW); + } + c.save(); + c.translate(x + dir * radius, (top + bottom) / 2.0f); + c.drawPath(sBulletPath, p); + c.restore(); + } else { + c.drawCircle(x + dir * radius, (top + bottom) / 2.0f, radius, p); + } + p.setColor(oldColor); + p.setStyle(style); + } + } + } + + @SuppressLint("ParcelCreator") + static class CustomTypefaceSpan extends TypefaceSpan { + + private final Typeface newType; + + private CustomTypefaceSpan(final Typeface type) { + super(""); + newType = type; + } + + @Override + public void updateDrawState(final TextPaint textPaint) { + apply(textPaint, newType); + } + + @Override + public void updateMeasureState(final TextPaint paint) { + apply(paint, newType); + } + + private void apply(final Paint paint, final Typeface tf) { + int oldStyle; + Typeface old = paint.getTypeface(); + if (old == null) { + oldStyle = 0; + } else { + oldStyle = old.getStyle(); + } + + int fake = oldStyle & ~tf.getStyle(); + if ((fake & Typeface.BOLD) != 0) { + paint.setFakeBoldText(true); + } + + if ((fake & Typeface.ITALIC) != 0) { + paint.setTextSkewX(-0.25f); + } + + paint.getShader(); + + paint.setTypeface(tf); + } + } + + static class CustomImageSpan extends CustomDynamicDrawableSpan { + private Drawable mDrawable; + private Uri mContentUri; + private int mResourceId; + + private CustomImageSpan(final Bitmap b, final int verticalAlignment) { + super(verticalAlignment); + mDrawable = new BitmapDrawable(CommonApplicationProxy.INSTANCE.getApplication().getResources(), b); + mDrawable.setBounds( + 0, 0, mDrawable.getIntrinsicWidth(), mDrawable.getIntrinsicHeight() + ); + } + + private CustomImageSpan(final Drawable d, final int verticalAlignment) { + super(verticalAlignment); + mDrawable = d; + mDrawable.setBounds( + 0, 0, mDrawable.getIntrinsicWidth(), mDrawable.getIntrinsicHeight() + ); + } + + private CustomImageSpan(final Uri uri, final int verticalAlignment) { + super(verticalAlignment); + mContentUri = uri; + } + + private CustomImageSpan(@DrawableRes final int resourceId, final int verticalAlignment) { + super(verticalAlignment); + mResourceId = resourceId; + } + + @Override + public Drawable getDrawable() { + Drawable drawable = null; + if (mDrawable != null) { + drawable = mDrawable; + } else if (mContentUri != null) { + Bitmap bitmap; + try { + InputStream is = + CommonApplicationProxy.INSTANCE.getApplication().getContentResolver().openInputStream(mContentUri); + bitmap = BitmapFactory.decodeStream(is); + drawable = new BitmapDrawable(CommonApplicationProxy.INSTANCE.getApplication().getResources(), bitmap); + drawable.setBounds( + 0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight() + ); + if (is != null) { + is.close(); + } + } catch (Exception e) { + Timber.e(e, "sms Failed to loaded content" + mContentUri); + } + } else { + try { + drawable = ContextCompat.getDrawable(CommonApplicationProxy.INSTANCE.getApplication(), mResourceId); + if (drawable!=null){ + drawable.setBounds( + 0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight() + ); + } + } catch (Exception e) { + Timber.e(e, "sms Unable to find resource:" + mResourceId); + } + } + return drawable; + } + } + + static abstract class CustomDynamicDrawableSpan extends ReplacementSpan { + + static final int ALIGN_BOTTOM = 0; + + static final int ALIGN_BASELINE = 1; + + static final int ALIGN_CENTER = 2; + + static final int ALIGN_TOP = 3; + + final int mVerticalAlignment; + + private CustomDynamicDrawableSpan() { + mVerticalAlignment = ALIGN_BOTTOM; + } + + private CustomDynamicDrawableSpan(final int verticalAlignment) { + mVerticalAlignment = verticalAlignment; + } + + public abstract Drawable getDrawable(); + + @Override + public int getSize(@NonNull final Paint paint, final CharSequence text, + final int start, final int end, final Paint.FontMetricsInt fm) { + Drawable d = getCachedDrawable(); + Rect rect = d.getBounds(); + if (fm != null) { +// LogUtils.d("fm.top: " + fm.top, +// "fm.ascent: " + fm.ascent, +// "fm.descent: " + fm.descent, +// "fm.bottom: " + fm.bottom, +// "lineHeight: " + (fm.bottom - fm.top)); + int lineHeight = fm.bottom - fm.top; + if (lineHeight < rect.height()) { + if (mVerticalAlignment == ALIGN_TOP) { + fm.top = fm.top; + fm.bottom = rect.height() + fm.top; + } else if (mVerticalAlignment == ALIGN_CENTER) { + fm.top = -rect.height() / 2 - lineHeight / 4; + fm.bottom = rect.height() / 2 - lineHeight / 4; + } else { + fm.top = -rect.height() + fm.bottom; + fm.bottom = fm.bottom; + } + fm.ascent = fm.top; + fm.descent = fm.bottom; + } + } + return rect.right; + } + + @Override + public void draw(@NonNull final Canvas canvas, final CharSequence text, + final int start, final int end, final float x, + final int top, final int y, final int bottom, @NonNull final Paint paint) { + Drawable d = getCachedDrawable(); + Rect rect = d.getBounds(); + canvas.save(); + float transY; + int lineHeight = bottom - top; +// LogUtils.d("rectHeight: " + rect.height(), +// "lineHeight: " + (bottom - top)); + if (rect.height() < lineHeight) { + if (mVerticalAlignment == ALIGN_TOP) { + transY = top; + } else if (mVerticalAlignment == ALIGN_CENTER) { + transY = (bottom + top - rect.height()) / 2; + } else if (mVerticalAlignment == ALIGN_BASELINE) { + transY = y - rect.height(); + } else { + transY = bottom - rect.height(); + } + canvas.translate(x, transY); + } else { + canvas.translate(x, top); + } + d.draw(canvas); + canvas.restore(); + } + + private Drawable getCachedDrawable() { + WeakReference wr = mDrawableRef; + Drawable d = null; + if (wr != null) { + d = wr.get(); + } + if (d == null) { + d = getDrawable(); + mDrawableRef = new WeakReference<>(d); + } + return d; + } + + private WeakReference mDrawableRef; + } + + static class ShaderSpan extends CharacterStyle implements UpdateAppearance { + private Shader mShader; + + private ShaderSpan(final Shader shader) { + this.mShader = shader; + } + + @Override + public void updateDrawState(final TextPaint tp) { + tp.setShader(mShader); + } + } + + static class ShadowSpan extends CharacterStyle implements UpdateAppearance { + private float radius; + private float dx, dy; + private int shadowColor; + + private ShadowSpan(final float radius, + final float dx, + final float dy, + final int shadowColor) { + this.radius = radius; + this.dx = dx; + this.dy = dy; + this.shadowColor = shadowColor; + } + + @Override + public void updateDrawState(final TextPaint tp) { + tp.setShadowLayer(radius, dx, dy, shadowColor); + } + } + + private static class SerializableSpannableStringBuilder extends SpannableStringBuilder + implements Serializable { + + private static final long serialVersionUID = 4909567650765875771L; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt new file mode 100644 index 0000000..e558511 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/StatusBarUtils.kt @@ -0,0 +1,194 @@ +package com.remax.visualnovel.utils + + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnAttach +import com.remax.visualnovel.R +import com.remax.visualnovel.configs.NovelApplication +import com.remax.visualnovel.constant.AppConstant +import com.remax.visualnovel.extension.findActivityContext +import timber.log.Timber +import kotlin.math.abs + + +object StatusBarUtils { + private val FAKE_STATUS_BAR_VIEW_ID = R.id.fake_status_bar_view + + /** + * 前景亮色/暗色 + * @param activity Activity + * @param isLight Boolean true:改为黑色 + */ + fun setStatusBarAndNavBarIsLight(activity: Activity, isLight: Boolean) { + WindowCompat.getInsetsController(activity.window, activity.window.decorView).isAppearanceLightStatusBars = isLight + WindowCompat.getInsetsController(activity.window, activity.window.decorView).isAppearanceLightNavigationBars = isLight + } + + var statusBarHeight = 0 + private var navBarHeight = 0 + + /** + * 获得状态栏的高度 + * Insets 只有在 view attached 才是可用的 + */ + @SuppressLint("InternalInsetResource", "DiscouragedApi") + fun getStatusBarHeight(activity: Activity) { + activity.window.decorView.doOnAttach { + /** + * 正确获取status bar方法,通过windowInset获取 + * getInsetsIgnoringVisibility 是获取到真实高度,无论状态栏是否隐藏 + * getInsets 是根据隐藏状态获取 + */ + val ignoringVisibilityTop = + ViewCompat.getRootWindowInsets(activity.window.decorView)?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.top ?: 0 + val ignoringVisibilityBottom = + ViewCompat.getRootWindowInsets(activity.window.decorView)?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.bottom ?: 0 + statusBarHeight = abs(ignoringVisibilityTop - ignoringVisibilityBottom) + /** + * 兼容,系统resources可能修改 + */ + if (statusBarHeight == 0) { + statusBarHeight = try { + activity.resources + .getIdentifier("status_bar_height", "dimen", AppConstant.ANDROID) + .takeIf { it > 0 } + ?.run { + activity.resources.getDimensionPixelSize(this) + } ?: 0 + } catch (_: Exception) { + 0 + } + } + } + } + + fun resetNavBarHeight(){ + navBarHeight = 0 + } + + /** + * 获得下方导航栏的高度 + * 获取的是可见高度,注释见上 + */ + fun getNavBarHeight(ignoringVisibility: Boolean = false): Int { + return if (navBarHeight == 0) { + NovelApplication.getCurrentActivity()?.run { + val rootWindowInsets = ViewCompat.getRootWindowInsets(window.decorView) + val typeMask = WindowInsetsCompat.Type.navigationBars() + val insets = if (ignoringVisibility) rootWindowInsets?.getInsetsIgnoringVisibility(typeMask) else rootWindowInsets?.getInsets(typeMask) + val top = insets?.top ?: 0 + val bottom = insets?.bottom ?: 0 + navBarHeight = abs(bottom - top) + Timber.d("获得下方导航栏的高度 $navBarHeight") + navBarHeight + } ?: 0 + } else { + navBarHeight + } + +// /** +// * 野路子,暂时兼容一下,系统resources可能修改 +// */ +// if (navBarHeight == 0) { +// val res: Resources = Resources.getSystem() +// val resourceId: Int = res.getIdentifier("navigation_bar_height", "dimen", AppConstant.ANDROID) +// resourceId.takeIf { it != 0 }?.run { +// navBarHeight = res.getDimensionPixelSize(this) +// } +// } + } + + /** + * 设置状态栏颜色 + * @param context 上下文,尽量使用Activity + * @param color 状态栏颜色 + */ + fun setColor(context: Context, @ColorInt color: Int) { + (context.findActivityContext() as? Activity)?.run { + window.apply { + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + statusBarColor = color + } + setStatusBarAndNavBarIsLight(this, !isDarkColor(color)) + } + } + + /** + * Android 5.0 以下版本设置状态栏颜色 + * + * @param window 窗口 + * @param color 状态栏颜色值 + * @param isTransparent 是否透明 + */ + fun setColor(window: Window, @ColorInt color: Int, isTransparent: Boolean) { + val context: Context = window.context + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + val decorView: ViewGroup = window.decorView as ViewGroup + val contentView = + decorView.findViewById(android.R.id.content) + val top = contentView?.paddingTop + Timber.d(top.toString()) + contentView?.setPadding(0, if (isTransparent) 0 else statusBarHeight, 0, 0) + val fakeStatusBarView = + decorView.findViewById(FAKE_STATUS_BAR_VIEW_ID) + if (fakeStatusBarView != null) { + fakeStatusBarView.setBackgroundColor(color) + if (fakeStatusBarView.visibility == View.GONE) { + fakeStatusBarView.visibility = View.VISIBLE + } + } else { + // 绘制一个和状态栏一样高的矩形 + val statusBarView = View(context) + val layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + statusBarHeight + ) + statusBarView.layoutParams = layoutParams + statusBarView.setBackgroundColor(color) + statusBarView.id = FAKE_STATUS_BAR_VIEW_ID + decorView.addView(statusBarView) + } + } + + /** + * 设置状态栏透明 + * + * @param context 上下文,尽量使用Activity + */ + fun setTransparent(context: Context) { + (context.findActivityContext() as? Activity)?.run { + this.window.apply { + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) + decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + statusBarColor = Color.TRANSPARENT + } + setStatusBarAndNavBarIsLight(this, false) + } + } + + /** + * 判断颜色是否为深色 + * + * @param color 要判断的颜色 + * @return 是否为深色 + */ + fun isDarkColor(@ColorInt color: Int) = ColorUtils.calculateLuminance(color) < 0.5 + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt new file mode 100644 index 0000000..306560b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/TimeUtils.kt @@ -0,0 +1,227 @@ +package com.remax.visualnovel.utils + +import android.content.Context +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * Created by HJW on 2025/7/11 + */ +object TimeUtils { + + private val context by lazy { + CommonApplicationProxy.application + } + + const val ONE_SECOND: Long = 1000 + + + const val ONE_MINUTE: Long = (60 * 1000).toLong() + + + const val ONE_HOUR: Long = 60 * ONE_MINUTE + + + const val ONE_DAY: Long = 24 * ONE_HOUR + + + const val ONE_WEEK: Long = 7 * ONE_DAY + + + const val ONE_MONTH: Long = 30 * ONE_DAY + + const val ONE_YEAR: Long = 365 * ONE_DAY + + const val YM_PATTERN: String = "yyyy-MM" + const val YMD_PATTERN: String = "yyyy-MM-dd" + const val YMD_PATTERN_2: String = "yyyyMMdd" + const val YMDHM_PATTERN: String = "yyyy-MM-dd HH:mm" + + fun getBirthDateString(context: Context, datetime: String, needYear: Boolean): String { + try { + val formatter = SimpleDateFormat(YMD_PATTERN, Locale.getDefault()) + val date = formatter.parse(datetime) + val timeMillis = date?.time ?: 0 + + val formatter2 = if (TimeUtils.isThisYear(timeMillis) && !needYear) + SimpleDateFormat(context.getString(R.string.time_format_m_d), Locale.getDefault()) + else + SimpleDateFormat(context.getString(R.string.time_format_y_m_d), Locale.getDefault()) + + return if (date != null) formatter2.format(date) else "" + } catch (e: ParseException) { + return datetime + } + } + + fun getBirthDate(datetime: String): Date { + try { + val formatter = SimpleDateFormat(YMD_PATTERN, Locale.getDefault()) + val date = formatter.parse(datetime) + return date ?: Date() + } catch (e: ParseException) { + return Date() + } + } + + /** + * 消息时间: + * 今天 hh:mm AM/PM + * 昨天 YDA hh:mm AM/PM + * 之前 MM-dd hh:mm AM/PM + */ + fun formatMsgTime(time: Long): String? { + var txtPreRes = 0 + val patternRes = when { + System.currentTimeMillis() - time < ONE_MINUTE -> { + txtPreRes = R.string.time_just_now + 0 + } + + //不在一年以内 + !isThisYear(time) -> { + R.string.time_format_y_m_d_h_m + } + + isToday(time) -> { + R.string.time_format_h_m + } + + isYesterday(time) -> { + txtPreRes = R.string.yesterday + R.string.time_format_h_m + } + + else -> { + R.string.time_format_m_d_h_m + } + } + + val txtPre = if (txtPreRes != 0) "${context.getString(txtPreRes)} " else "" + val txtTime = if (patternRes != 0) { + SimpleDateFormat(context.getString(patternRes), Locale.getDefault()).format(Date(time)) + } else "" + + return txtPre + txtTime + } + + /** + * 是否是昨天 + */ + fun isYesterday(time: Long): Boolean { + val pre = Calendar.getInstance() + val predate = Date(System.currentTimeMillis()) + pre.setTime(predate) + + val cal = Calendar.getInstance() + val date = Date(time) + cal.setTime(date) + + if (cal.get(Calendar.YEAR) == (pre.get(Calendar.YEAR))) { + val diffDay = (cal.get(Calendar.DAY_OF_YEAR) + - pre.get(Calendar.DAY_OF_YEAR)) + return diffDay == -1 + } + return false + } + + /** + * 是否是同一天 + */ + fun isSameDay(firstTime: Long, secondTime: Long): Boolean { + val pre = Calendar.getInstance() + val predate = Date(firstTime) + pre.setTime(predate) + + val cal = Calendar.getInstance() + val date = Date(secondTime) + cal.setTime(date) + + if (cal.get(Calendar.YEAR) == (pre.get(Calendar.YEAR))) { + val diffDay = (cal.get(Calendar.DAY_OF_YEAR) + - pre.get(Calendar.DAY_OF_YEAR)) + + return diffDay == 0 + } + return false + } + + /** + * 是否是今天 + */ + fun isToday(time: Long): Boolean { + val pre = Calendar.getInstance() + val predate = Date(System.currentTimeMillis()) + pre.setTime(predate) + + val cal = Calendar.getInstance() + val date = Date(time) + cal.setTime(date) + + if (cal.get(Calendar.YEAR) == (pre.get(Calendar.YEAR))) { + val diffDay = (cal.get(Calendar.DAY_OF_YEAR) + - pre.get(Calendar.DAY_OF_YEAR)) + + return diffDay == 0 + } + return false + } + + /** + * 当前时间加1年 + * 是否是今年 + */ + fun isThisYear(time: Long): Boolean { + val date = Date() + val sdf = SimpleDateFormat(YMDHM_PATTERN, Locale.getDefault()) + val now = sdf.format(date) + val handleTime = sdf.format(Date(time)) + return now.substring(0, 4) == handleTime.substring(0, 4) + } + + fun format_y_m_d_h_m(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m_d_h_m), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_m_d_h_m_s(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_m_d_h_m_s), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_h_m_a(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_h_m), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y_m_d_h_m_s(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m_d_h_m_s), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y_m_d(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m_d), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y_m(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y_m), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_m_d(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_m_d), Locale.getDefault()) + return format.format(Date(date)) + } + + fun format_y(date: Long): String { + val format = SimpleDateFormat(context.getString(R.string.time_format_y), Locale.getDefault()) + return format.format(Date(date)) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt new file mode 100644 index 0000000..0f63d65 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStoreOwner.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023. Dylan Cai + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +package com.remax.visualnovel.utils.datastore + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +open class DataStoreOwner(name: String) : IDataStoreOwner { + private val Context.dataStore by preferencesDataStore(name) + override val dataStore get() = context.dataStore +} + +interface IDataStoreOwner { + val context: Context get() = application + + val dataStore: DataStore + + fun intPreference(default: Int? = null): ReadOnlyProperty> = + PreferenceProperty(::intPreferencesKey, default) + + fun doublePreference(default: Double? = null): ReadOnlyProperty> = + PreferenceProperty(::doublePreferencesKey, default) + + fun longPreference(default: Long? = null): ReadOnlyProperty> = + PreferenceProperty(::longPreferencesKey, default) + + fun floatPreference(default: Float? = null): ReadOnlyProperty> = + PreferenceProperty(::floatPreferencesKey, default) + + fun booleanPreference(default: Boolean? = null): ReadOnlyProperty> = + PreferenceProperty(::booleanPreferencesKey, default) + + fun stringPreference(default: String? = null): ReadOnlyProperty> = + PreferenceProperty(::stringPreferencesKey, default) + + fun stringSetPreference(default: Set? = null): ReadOnlyProperty>> = + PreferenceProperty(::stringSetPreferencesKey, default) + + class PreferenceProperty( + private val key: (String) -> Preferences.Key, + private val default: V? = null, + ) : ReadOnlyProperty> { + private var cache: DataStorePreference? = null + + override fun getValue(thisRef: IDataStoreOwner, property: KProperty<*>): DataStorePreference = + cache ?: DataStorePreference(thisRef.dataStore, key(property.name), default).also { cache = it } + } + + companion object { + internal lateinit var application: Application + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt new file mode 100644 index 0000000..67933be --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/datastore/DataStorePreference.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023. Dylan Cai + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused", "MemberVisibilityCanBePrivate") + +package com.remax.visualnovel.utils.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +operator fun Preferences.get(preference: DataStorePreference) = this[preference.key] + +open class DataStorePreference( + private val dataStore: DataStore, + val key: Preferences.Key, + open val default: V? +) { + + suspend fun set(block: suspend V?.(Preferences) -> V?): Preferences = + dataStore.edit { preferences -> + val value = block(preferences[key] ?: default, preferences) + if (value == null) { + preferences.remove(key) + } else { + preferences[key] = value + } + } + + suspend fun set(value: V?): Preferences = set { value } + + fun asFlow(): Flow = + dataStore.data.map { it[key] ?: default } + + fun asLiveData(): LiveData = asFlow().asLiveData() + + suspend fun get(): V? = asFlow().first() + + suspend fun getOrDefault(): V = get() ?: throw IllegalStateException("No default value") +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt new file mode 100644 index 0000000..dfcce91 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/mmkv/MMKVExt.kt @@ -0,0 +1,100 @@ +package com.remax.visualnovel.utils.mmkv + +import android.os.Parcelable +import androidx.lifecycle.MutableLiveData +import com.tencent.mmkv.MMKV +import kotlin.properties.ReadOnlyProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * A class that has a MMKV instance. If you want to customize the MMKV, you can override + * the kv property. For example: + * + * ```kotlin + * object DataRepository : MMKVOwner { + * override val kv = MMKV.mmkvWithID("MyID") + * } + * ``` + */ +interface IMMKVOwner { + val mmapID: String + val kv: MMKV +} + +open class MMKVOwner(override val mmapID: String) : IMMKVOwner { + override val kv: MMKV by lazy { MMKV.mmkvWithID(mmapID) } +} + +fun IMMKVOwner.mmkvInt(default: Int = 0) = + MMKVProperty({ kv.decodeInt(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvLong(default: Long = 0L) = + MMKVProperty({ kv.decodeLong(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvBool(default: Boolean = false) = + MMKVProperty({ kv.decodeBool(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvFloat(default: Float = 0f) = + MMKVProperty({ kv.decodeFloat(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvDouble(default: Double = 0.0) = + MMKVProperty({ kv.decodeDouble(it, default) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvString() = + MMKVProperty({ kv.decodeString(it) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvString(default: String) = + MMKVProperty({ kv.decodeString(it) ?: default }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvStringSet() = + MMKVProperty({ kv.decodeStringSet(it) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvStringSet(default: Set) = + MMKVProperty({ kv.decodeStringSet(it) ?: default }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvBytes() = + MMKVProperty({ kv.decodeBytes(it) }, { kv.encode(first, second) }) + +fun IMMKVOwner.mmkvBytes(default: ByteArray) = + MMKVProperty({ kv.decodeBytes(it) ?: default }, { kv.encode(first, second) }) + +inline fun IMMKVOwner.mmkvParcelable() = + MMKVProperty({ kv.decodeParcelable(it, T::class.java) }, { kv.encode(first, second) }) + +inline fun IMMKVOwner.mmkvParcelable(default: T) = + MMKVProperty({ kv.decodeParcelable(it, T::class.java) ?: default }, { kv.encode(first, second) }) + +fun MMKVProperty.asLiveData() = object : ReadOnlyProperty> { + private var cache: MutableLiveData? = null + + override fun getValue(thisRef: IMMKVOwner, property: KProperty<*>): MutableLiveData = + cache ?: object : MutableLiveData() { + override fun getValue() = this@asLiveData.getValue(thisRef, property) + + override fun setValue(value: V) { + if (super.getValue() == value) return + this@asLiveData.setValue(thisRef, property, value) + super.setValue(value) + } + + override fun onActive() = super.setValue(value) + }.also { cache = it } +} + + +class MMKVProperty( + private val decode: (String) -> V, + private val encode: Pair.() -> Boolean +) : ReadWriteProperty { + private var cache: V? = null + + override fun getValue(thisRef: IMMKVOwner, property: KProperty<*>): V = + cache ?: decode(property.name).also { cache = it } + + override fun setValue(thisRef: IMMKVOwner, property: KProperty<*>, value: V) { + if (encode(property.name to value)) { + cache = value + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt new file mode 100644 index 0000000..878c7d1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/ReplaceRule.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex + +import androidx.annotation.Keep +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanReplacementMatch + +/** + * 替换规则 + */ +@Keep +data class ReplaceRule( + /** + * 查找的字符串或正则文本 + */ + val replaceString: String, + /** + * [replaceString]是否为正则 + */ + val isRegex: Boolean, + /** + * 匹配范围 + */ + val matchRange: IntRange?, + /** + * 替换文本(null 为不替换) + */ + val newString: CharSequence?, + /** + * 匹配时回调 + */ + val replacementMatch: OnSpanReplacementMatch? +) { + internal val replaceRules: Regex + get() = (if (isRegex) replaceString else Regex.escape(replaceString)).toRegex() +} + + +/** + * 创建替换规则 + * @receiver 查找的字符串或正则文本 + * @param isRegex receiver是否为正则 + * @param matchIndex 单一匹配位置 ([matchRange]不为null时优先使用[matchRange]) + * @param matchRange 匹配范围 + * @param newString 替换文本(null 为不替换) + * @param replacementMatch 匹配时回调 + */ +fun String.toReplaceRule( + isRegex: Boolean = false, + matchIndex: Int? = null, + matchRange: IntRange? = null, + newString: CharSequence? = null, + replacementMatch: OnSpanReplacementMatch? = null +): ReplaceRule = ReplaceRule( + replaceString = this, + isRegex = isRegex, + matchRange = matchRange ?: matchIndex?.let { matchIndex..matchIndex }, + newString = newString, + replacementMatch = replacementMatch +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt new file mode 100644 index 0000000..888ba5f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/Span.kt @@ -0,0 +1,932 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("Span") +@file:Suppress("unused") + +package com.remax.visualnovel.utils.spannablex + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.* +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.* +import android.text.method.LinkMovementMethod +import android.text.style.* +import android.widget.TextView +import androidx.annotation.* +import androidx.annotation.IntRange +import androidx.core.text.buildSpannedString +import com.bumptech.glide.request.RequestOptions +import com.drake.spannable.movement.ClickableMovementMethod +import com.drake.spannable.span.CenterImageSpan +import com.drake.spannable.span.GlideImageSpan +import com.drake.spannable.span.MarginSpan +import com.remax.visualnovel.utils.spannablex.annotation.ConversionUnit +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanReplacementMatch +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize +import com.remax.visualnovel.utils.spannablex.utils.drawableSize +import com.remax.visualnovel.utils.spannablex.utils.color +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.utils.spannablex.utils.sp +import java.util.* + +/** + *

Chain Spannable

+ * sample: + *
+ *
TextView.setText(Span.create() + *
.text("this is real text.") + *
.text("spannable").color(Color.BLUE).style(Typeface.BOLD) + *
.spannable()) + */ +class Span private constructor() { + + private val spannableBuilder = SpannableStringBuilder() + private var spannableCache: Spannable? = null + + private val Spannable?.isNotNullAndEmpty: Boolean + get() = this != null && this.isNotEmpty() + + private fun runOnSelf(block: () -> Spannable?): Span = apply { + block.invoke()?.let { spannableCache = it } + } + + private fun checkImageSpan(autoPlaceholder: Boolean = false) { + if (autoPlaceholder) { + saveCache() + spannableCache = SpannableString(" ") + } + } + + /** + * 保存当前 [text] spannable(大部分情况不需要手动调用) + */ + fun saveCache(): Span = apply { + if (spannableCache.isNotNullAndEmpty) { + spannableBuilder.append(spannableCache) + } + } + + /** + * 插入待处理字符串 + * 在使用[style] [typeface] [color]... 等等之前,需调用该方法插入当前需要处理的字符串 + */ + fun text(text: CharSequence): Span = apply { + saveCache() + spannableCache = if (text is Spannable) { + text + } else SpannableString(text) + } + + /** + * 换行(可自行处理`\n`) + */ + @JvmOverloads + fun newline(@IntRange(from = 1L) lines: Int = 1): Span = apply { + val newlines = if (lines > 1) { + buildString { + repeat(lines) { append("\n") } + } + } else "\n" + when (val cache = spannableCache) { + is SpannableStringBuilder -> cache.append(newlines) + is Spanned -> spannableCache = SpannableStringBuilder(cache).append(newlines) + is CharSequence -> spannableCache = SpannableString(cache + newlines) + else -> spannableBuilder.append(newlines) + } + } + + /** + * 构建Spannable + */ + fun spannable(): CharSequence { + saveCache() + spannableCache = null + return SpannedString(spannableBuilder) + } + + /** + * [StyleSpan] 设置文本样式 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun style( + @TextStyle style: Int, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanStyle(style, replaceRule) } + + /** + * [TypefaceSpan] 设置字体样式 + * + * @param typeface 字体(API>=28) + * @param family 字体集 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun typeface( + typeface: Typeface? = null, + family: String? = null, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanTypeface(typeface, family, replaceRule) } + + /** + * [TextAppearanceSpan] 设置字体效果spanTypeface + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param size 文本大小 + * @param color 文本颜色 + * @param family 字体集 + * @param linkColor 链接颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun textAppearance( + @TextStyle style: Int = Typeface.NORMAL, + @Px size: Int = -1, + @ColorInt color: Int? = null, + family: String? = null, + linkColor: ColorStateList? = null, + replaceRule: Any? = null + ): Span = runOnSelf { + spannableCache?.spanTextAppearance( + style, + size, + color, + family, + linkColor, + replaceRule + ) + } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param color 文本颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun color( + @ColorInt color: Int, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanColor(color, replaceRule) } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param colorString 文本颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun color( + colorString: String, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanColor(Companion.color(colorString), replaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param color 背景颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun background( + @ColorInt color: Int, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanBackground(color, replaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun background( + colorString: String, + replaceRule: Any? = null + ): Span = + runOnSelf { spannableCache?.spanBackground(Companion.color(colorString), replaceRule) } + + /** + * [CenterImageSpan] 图片 + * + * @param drawable [Drawable] + * @param source [Drawable] Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + drawable: Drawable, + source: String? = null, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + drawable, + source, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param uri 图片 Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + context: Context, + uri: Uri, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + context, + uri, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param resourceId 图片Id + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + context: Context, + @DrawableRes resourceId: Int, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + context, + resourceId, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param bitmap [Bitmap] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun image( + context: Context, + bitmap: Bitmap, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanImage( + context, + bitmap, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: CenterImageSpan.Align.CENTER, + replaceRule + ) + } + + /** + * [GlideImageSpan] 图片 + * + * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新 + * @param url 图片地址参见 [Glide.with(view).load(url)] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun glide( + view: TextView, + url: Any, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: GlideImageSpan.Align? = null, + loopCount: Int? = null, + requestOption: RequestOptions? = null, + replaceRule: Any? = null, + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanGlide( + view, + url, + useTextViewSize, + size, + marginLeft, + marginRight, + align ?: GlideImageSpan.Align.CENTER, + loopCount, + requestOption, + replaceRule + ) + } + + /** + * [ScaleXSpan] X轴文本缩放 + * + * @param proportion 水平(X轴)缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun scaleX( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanScaleX(proportion, replaceRule) } + + + /** + * [MaskFilterSpan] 设置文本蒙版效果 + * + * @param filter 蒙版效果 [MaskFilter] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun maskFilter( + filter: MaskFilter, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanMaskFilter(filter, replaceRule) } + + /** + * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果 + * + * @param radius 模糊半径 + * @param style 模糊效果 [BlurMaskFilter.Blur] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun blurMask( + @FloatRange(from = 0.0) radius: Float, + style: BlurMaskFilter.Blur? = null, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanBlurMask(radius, style, replaceRule) } + + /** + * [SuperscriptSpan] 设置文本为上标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun superscript( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanSuperscript(replaceRule) } + + /** + * [SubscriptSpan] 设置文本为下标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun subscript( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanSubscript(replaceRule) } + + /** + * [AbsoluteSizeSpan] 设置文本绝对大小 + * + * @param size 文本大小 + * @param dp true = [size] dp, false = [size] px + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun absoluteSize( + size: Int, + dp: Boolean = true, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanAbsoluteSize(size, dp, replaceRule) } + + /** + * [RelativeSizeSpan] 设置文本相对大小 + * + * @param proportion 文本缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun relativeSize( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanRelativeSize(proportion, replaceRule) } + + /** + * [StrikethroughSpan] 设置文本删除线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun strikethrough( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanStrikethrough(replaceRule) } + + /** + * [UnderlineSpan] 设置文本下划线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun underline( + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanUnderline(replaceRule) } + + /** + * [URLSpan] 设置文本超链接 + * + * 需配合[TextView.activateClick]使用 + * @param url 超链接地址 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun url( + url: String, + replaceRule: Any? = null + ): Span = runOnSelf { spannableCache?.spanURL(url, replaceRule) } + + /** + * [SuggestionSpan] 设置文本输入提示 + * + * @param context [Context] + * @param suggestions 提示规则文本数组 + * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION] + * @param locale 语言区域设置 + * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun suggestion( + context: Context, + suggestions: Array, + flags: Int = SuggestionSpan.FLAG_EASY_CORRECT or SuggestionSpan.FLAG_AUTO_CORRECTION, + locale: Locale? = null, + notificationTargetClass: Class<*>? = null, + replaceRule: Any? = null + ): Span = runOnSelf { + spannableCache?.spanSuggestion( + context, + suggestions = suggestions, + flags, + locale, + notificationTargetClass, + replaceRule + ) + } + + /** + * [SimpleClickableSpan] 设置文本点击效果 + * + * @param color 文本颜色 + * @param backgroundColor 背景颜色 + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param config 附加配置 [SimpleClickableConfig] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * @param onClick [OnSpanClickListener] 点击回调 + */ + @JvmOverloads + fun clickable( + @ColorInt color: Int? = null, + @ColorInt backgroundColor: Int? = null, + @TextStyle style: Int? = null, + config: SimpleClickableConfig? = null, + replaceRule: Any? = null, + onClick: OnSpanClickListener? = null + ): Span = runOnSelf { + spannableCache?.spanClickable( + color, + backgroundColor, + style, + config, + replaceRule, + onClick + ) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param color 间距填充颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun margin( + @Px width: Int, + @ColorInt color: Int = Color.TRANSPARENT, + replaceRule: Any? = null + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanMargin(width, color, replaceRule) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param colorString 间距填充颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun margin( + @Px width: Int, + colorString: String?, + replaceRule: Any? = null + ): Span = runOnSelf { + checkImageSpan(replaceRule == null) + spannableCache?.spanMargin(width, colorString?.takeIf(String::isNotBlank)?.color ?: Color.TRANSPARENT, replaceRule) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 竖线颜色 + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun quote( + @ColorInt color: Int, + @Px @IntRange(from = 0) stripeWidth: Int = 10, + @Px @IntRange(from = 0) gapWidth: Int = 0 + ): Span = runOnSelf { + spannableCache?.spanQuote(color, stripeWidth, gapWidth) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 竖线颜色 #RRGGBB #AARRGGBB + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun quote( + colorString: String, + @IntRange(from = 0) stripeWidth: Int = 10, + @IntRange(from = 0) gapWidth: Int = 0 + ): Span = runOnSelf { + spannableCache?.spanQuote(colorString.color, stripeWidth, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 圆形颜色 + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun bullet( + @ColorInt color: Int, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ): Span = runOnSelf { + spannableCache?.spanBullet(color, bulletRadius, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 圆形颜色 #RRGGBB #AARRGGBB + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + @JvmOverloads + fun bullet( + colorString: String, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ): Span = runOnSelf { + spannableCache?.spanBullet(colorString.color, bulletRadius, gapWidth) + } + + /** + * [AlignmentSpan] 设置段落对齐方式 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE] + */ + fun alignment( + align: Layout.Alignment + ): Span = runOnSelf { + spannableCache?.spanAlignment(align) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 背景颜色 + */ + fun lineBackground( + @ColorInt color: Int + ): Span = runOnSelf { + spannableCache?.spanLineBackground(color) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + */ + fun lineBackground( + colorString: String + ): Span = runOnSelf { + spannableCache?.spanLineBackground(colorString.color) + } + + /** + * [LeadingMarginSpan] 设置段落文本缩进 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param firstLines 首行行数. 与[firstMargin]关联 + * @param firstMargin 首行左边距(缩进) + * @param restMargin 剩余行(非首行)左边距(缩进) + */ + @JvmOverloads + fun leadingMargin( + @IntRange(from = 1L) firstLines: Int, + @Px firstMargin: Int, + @Px restMargin: Int = 0 + ): Span = runOnSelf { + spannableCache?.spanLeadingMargin(firstLines, firstMargin, restMargin) + } + + /** + * [LineHeightSpan] 设置段落行高 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param height 行高 + */ + fun lineHeight( + @Px @IntRange(from = 1L) height: Int + ): Span = runOnSelf { + spannableCache?.spanLineHeight(height) + } + + /** + * [ParagraphBitmapSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param bitmap [Bitmap] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + @JvmOverloads + fun imageParagraph( + bitmap: Bitmap, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ): Span = runOnSelf { + spannableCache?.spanImageParagraph(bitmap, padding, useTextViewSize, size) + } + + /** + * [ParagraphDrawableSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param drawable [Drawable] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + @JvmOverloads + fun imageParagraph( + drawable: Drawable, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ): Span = runOnSelf { + spannableCache?.spanImageParagraph(drawable, padding, useTextViewSize, size) + } + + /** + * 自定义字符样式 + * + * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + @JvmOverloads + fun custom( + style: T, + replaceRule: Any? = null, + ): Span = runOnSelf { + spannableCache?.spanCustom(style, replaceRule) + } + + /** + * 自定义段落样式 + * + * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效 + */ + @JvmOverloads + fun custom( + style: T, + replaceRule: Any? = null, + ): Span = runOnSelf { + spannableCache?.spanCustom(style, replaceRule) + } + + companion object { + /** + * 构建Span + * @see [Span] + */ + @JvmStatic + fun create(): Span = Span() + + // + /** + * 适配Java不支持CharSequence operator plus + * eg. `SpanExtension.spannedString(spanImage(...),spanColor(..))` + */ + @JvmStatic + fun spannedString(vararg texts: CharSequence): SpannedString = buildSpannedString { + texts.forEach(this::append) + } + + /** + * 兼容Java适配 @see [toReplaceRule] + * + * @param replaceString 查找的字符串或正则文本 + * @param isRegex [replaceString]是否为正则 + * @param matchIndex 单一匹配位置 ([matchRange]不为null时优先使用[matchRange]) + * @param matchRange 匹配范围 + * @param newString 替换文本(null 为不替换) + * @param replacementMatch 匹配时回调 + */ + @JvmStatic + @JvmOverloads + fun toReplaceRule( + replaceString: String, + isRegex: Boolean = false, + matchIndex: Int? = null, + matchRange: kotlin.ranges.IntRange? = null, + newString: CharSequence? = null, + replacementMatch: OnSpanReplacementMatch? = null + ): ReplaceRule = + replaceString.toReplaceRule( + isRegex, + matchIndex, + matchRange, + newString, + replacementMatch + ) + + /** + * 快速构建 [DrawableSize] + */ + @JvmStatic + @JvmOverloads + fun drawableSize( + size: Int, + @ConversionUnit unit: Int = ConversionUnit.NOT_CONVERT, + ): DrawableSize = + size.let { + when (unit) { + ConversionUnit.SP -> it.sp + ConversionUnit.DP -> it.dp + else -> it + } + }.drawableSize + + + /** + * dp 2 px + */ + @JvmStatic + fun dp(value: Int): Int = value.dp + + /** + * sp 2 px + */ + @JvmStatic + fun sp(value: Int): Int = value.sp + + @JvmStatic + fun color(colorString: String): Int = colorString.color + + /** + * 删除所有[CharacterStyle] Span + */ + @JvmStatic + fun removeAllSpans(span: Spannable) { + span.removeAllSpans() + } + + @JvmStatic + fun removeSpans(text: CharSequence, type: Class<*>): CharSequence = + (if (text is Spannable) text else SpannableString(text)).apply { + val allSpans = getSpans(0, length, type) + for (span in allSpans) { + removeSpan(span) + } + } + + /** + * 兼容Java适配 @see [TextView.activateClick] + * + * 配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param textView 需要配置点击效果的[TextView] + * @param background 是否显示点击背景 + */ + @JvmStatic + @JvmOverloads + fun activateClick(textView: TextView, background: Boolean = true): TextView = + textView.activateClick(background) + // + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt new file mode 100644 index 0000000..087e71a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanDsl.kt @@ -0,0 +1,812 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") + +package com.remax.visualnovel.utils.spannablex + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.* +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* +import android.widget.TextView +import androidx.annotation.* +import androidx.annotation.IntRange +import com.bumptech.glide.request.RequestOptions +import com.drake.spannable.span.CenterImageSpan +import com.drake.spannable.span.GlideImageSpan +import com.drake.spannable.span.MarginSpan +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize +import com.remax.visualnovel.utils.spannablex.utils.color +import java.util.* + +/** + * DSL Spannable + * + * sample: ```kotlin + * TextView.text = spannable { + * "this is real text.".text() + * "spannable".color(Color.BLUE).style(Typeface.BOLD) + * } + * ``` + */ +class SpanDsl private constructor( + private val text: CharSequence?, + private val globalReplaceRule: Any? +) { + + /** + * [text] SpannedString + */ + var textSpannable: Spanned? = text?.let { + when (text) { + is Spanned -> text + else -> SpannedString(text) + } + } + private set + + private val spannableBuilder = SpannableStringBuilder() + + internal fun spannable(): SpannableStringBuilder = + spannableBuilder.apply { textSpannable?.let { ts -> insert(0, ts) } } + + /** + * 设置单个Span + */ + private fun Any?.singleSpan( + autoPlaceholder: Boolean = false, + span: CharSequence.() -> Spanned + ) { + when { + this is CharSequence -> { + spannableBuilder.append(span.invoke(this)) + } + autoPlaceholder -> { + spannableBuilder.append(span.invoke(IMAGE_SPAN_TAG)) + } + textSpannable != null -> { + textSpannable = span.invoke(textSpannable!!) + } + } + } + + /** + * 添加文本(无Spannable效果) + */ + fun CharSequence?.text() { + this?.let(spannableBuilder::append) + } + + /** + * 为 @receiver 设置多个Span + */ + fun CharSequence?.span(replaceRule: Any? = null, span: SpanDsl.() -> Unit = {}) { + mixed(replaceRule, span) + } + + /** + * 为 @receiver 设置多个Span + */ + fun CharSequence?.mixed(replaceRule: Any? = null, span: SpanDsl.() -> Unit = {}) { + spannableBuilder.append( + create(this, replaceRule ?: globalReplaceRule).apply(span).spannable() + ) + } + + /** + * 换行(可自行处理`\n`) + */ + fun T?.newline(@IntRange(from = 1L) lines: Int = 1): CharSequence? = + run { + val newlines = if (lines > 1) { + buildString { + repeat(lines) { append("\n") } + } + } else "\n" + when (this) { + is SpannableStringBuilder -> append(newlines) + is Spanned -> SpannableStringBuilder(this).append(newlines) + is String -> "${this}$newlines" + is CharSequence -> "${this}$newlines" + else -> { + spannableBuilder.append(newlines) + null + } + } + } + + /** + * [StyleSpan] 设置文本样式 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.style( + @TextStyle style: Int, + replaceRule: Any? = null + ) = singleSpan { spanStyle(style, replaceRule ?: globalReplaceRule) } + + /** + * [TypefaceSpan] 设置字体样式 + * + * @param typeface 字体(API>=28) + * @param family 字体集 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.typeface( + typeface: Typeface? = null, + family: String? = null, + replaceRule: Any? = null + ) = singleSpan { spanTypeface(typeface, family, replaceRule ?: globalReplaceRule) } + + /** + * [TextAppearanceSpan] 设置字体效果spanTypeface + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param size 文本大小 + * @param color 文本颜色 + * @param family 字体集 + * @param linkColor 链接颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.textAppearance( + @TextStyle style: Int = Typeface.NORMAL, + @Px size: Int = -1, + @ColorInt color: Int? = null, + family: String? = null, + linkColor: ColorStateList? = null, + replaceRule: Any? = null + ) = singleSpan { + spanTextAppearance( + style, + size, + color, + family, + linkColor, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param color 文本颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.color( + @ColorInt color: Int, + replaceRule: Any? = null + ) = singleSpan { spanColor(color, replaceRule ?: globalReplaceRule) } + + /** + * [ForegroundColorSpan] 文本颜色 + * + * @param colorString 文本颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.color( + colorString: String, + replaceRule: Any? = null + ) = singleSpan { spanColor(colorString.color, replaceRule ?: globalReplaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param color 背景颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.background( + @ColorInt color: Int, + replaceRule: Any? = null + ) = singleSpan { spanBackground(color, replaceRule ?: globalReplaceRule) } + + /** + * [BackgroundColorSpan] 背景颜色 + * + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.background( + colorString: String, + replaceRule: Any? = null + ) = singleSpan { spanBackground(colorString.color, replaceRule ?: globalReplaceRule) } + + /** + * [CenterImageSpan] 图片 + * + * @param drawable [Drawable] + * @param source [Drawable] Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + drawable: Drawable, + source: String? = null, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + drawable, + source, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param uri 图片 Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + context: Context, + uri: Uri, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + context, + uri, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param resourceId 图片Id + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + context: Context, + @DrawableRes resourceId: Int, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + context, + resourceId, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param bitmap [Bitmap] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.image( + context: Context, + bitmap: Bitmap, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: CenterImageSpan.Align = CenterImageSpan.Align.CENTER, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanImage( + context, + bitmap, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [GlideImageSpan] 图片 + * + * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新 + * @param url 图片地址参见 [Glide.with(view).load(url)] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.glide( + view: TextView, + url: Any, + useTextViewSize: TextView? = null, + size: DrawableSize? = null, + @Px marginLeft: Int? = null, + @Px marginRight: Int? = null, + align: GlideImageSpan.Align = GlideImageSpan.Align.CENTER, + loopCount: Int? = null, + requestOption: RequestOptions? = null, + replaceRule: Any? = null, + ) = singleSpan(replaceRule == null) { + spanGlide( + view, + url, + useTextViewSize, + size, + marginLeft, + marginRight, + align, + loopCount, + requestOption, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [ScaleXSpan] X轴文本缩放 + * + * @param proportion 水平(X轴)缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.scaleX( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ) = singleSpan { + spanScaleX(proportion, replaceRule ?: globalReplaceRule) + } + + /** + * [MaskFilterSpan] 设置文本蒙版效果 + * + * @param filter 蒙版效果 [MaskFilter] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.maskFilter( + filter: MaskFilter, + replaceRule: Any? = null + ) = singleSpan { + spanMaskFilter(filter, replaceRule ?: globalReplaceRule) + } + + /** + * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果 + * + * @param radius 模糊半径 + * @param style 模糊效果 [BlurMaskFilter.Blur] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.blurMask( + @FloatRange(from = 0.0) radius: Float, + style: BlurMaskFilter.Blur = BlurMaskFilter.Blur.NORMAL, + replaceRule: Any? = null + ) = singleSpan { + spanBlurMask(radius, style, replaceRule ?: globalReplaceRule) + } + + /** + * [SuperscriptSpan] 设置文本为上标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.superscript(replaceRule: Any? = null) = singleSpan { + spanSuperscript(replaceRule ?: globalReplaceRule) + } + + /** + * [SubscriptSpan] 设置文本为下标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.subscript(replaceRule: Any? = null) = singleSpan { + spanSubscript(replaceRule ?: globalReplaceRule) + } + + /** + * [AbsoluteSizeSpan] 设置文本绝对大小 + * + * @param size 文本大小 + * @param dp true = [size] dp, false = [size] px + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.absoluteSize( + size: Int, + dp: Boolean = true, + replaceRule: Any? = null + ) = + singleSpan { + spanAbsoluteSize(size, dp, replaceRule ?: globalReplaceRule) + } + + /** + * [RelativeSizeSpan] 设置文本相对大小 + * + * @param proportion 文本缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.relativeSize( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? = null + ) = singleSpan { + spanRelativeSize(proportion, replaceRule ?: globalReplaceRule) + } + + /** + * [StrikethroughSpan] 设置文本删除线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.strikethrough(replaceRule: Any? = null) = singleSpan { + spanStrikethrough(replaceRule ?: globalReplaceRule) + } + + /** + * [UnderlineSpan] 设置文本下划线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.underline(replaceRule: Any? = null) = singleSpan { + spanUnderline(replaceRule ?: globalReplaceRule) + } + + /** + * [URLSpan] 设置文本超链接 + * + * 需配合[TextView.activateClick]使用 + * @param url 超链接地址 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.url(url: String, replaceRule: Any? = null) = singleSpan { + spanURL(url, replaceRule ?: globalReplaceRule) + } + + /** + * [SuggestionSpan] 设置文本输入提示 + * + * @param context [Context] + * @param suggestions 提示规则文本数组 + * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION] + * @param locale 语言区域设置 + * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.suggestion( + context: Context, + suggestions: Array, + flags: Int = SuggestionSpan.FLAG_EASY_CORRECT or SuggestionSpan.FLAG_AUTO_CORRECTION, + locale: Locale? = null, + notificationTargetClass: Class<*>? = null, + replaceRule: Any? = null + ) = singleSpan { + spanSuggestion( + context, + suggestions, + flags, + locale, + notificationTargetClass, + replaceRule ?: globalReplaceRule + ) + } + + /** + * [SimpleClickableSpan] 设置文本点击效果 + * + * @param color 文本颜色 + * @param backgroundColor 背景颜色 + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param config 附加配置 [SimpleClickableConfig] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * @param onClick [OnSpanClickListener] 点击回调 + */ + fun Any?.clickable( + @ColorInt color: Int? = null, + @ColorInt backgroundColor: Int? = null, + @TextStyle style: Int? = null, + config: SimpleClickableConfig? = null, + replaceRule: Any? = null, + onClick: OnSpanClickListener? = null + ) = singleSpan { + spanClickable( + color, + backgroundColor, + style, + config, + replaceRule ?: globalReplaceRule, + onClick + ) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param color 间距填充颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.margin( + @Px width: Int, + @ColorInt color: Int = Color.TRANSPARENT, + replaceRule: Any? = null + ) = singleSpan(replaceRule == null) { + spanMargin(width, color, replaceRule) + } + + /** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param colorString 间距填充颜色 #RRGGBB #AARRGGBB + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.margin( + @Px width: Int, + colorString: String, + replaceRule: Any? = null + ) = singleSpan(replaceRule == null) { + spanMargin(width, colorString.color, replaceRule) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 竖线颜色 + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.quote( + @ColorInt color: Int, + @Px @IntRange(from = 0) stripeWidth: Int = 10, + @Px @IntRange(from = 0) gapWidth: Int = 0 + ) = singleSpan { + spanQuote(color, stripeWidth, gapWidth) + } + + /** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 竖线颜色 #RRGGBB #AARRGGBB + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.quote( + colorString: String, + @IntRange(from = 0) stripeWidth: Int = 10, + @IntRange(from = 0) gapWidth: Int = 0 + ) = singleSpan { + spanQuote(colorString.color, stripeWidth, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 圆形颜色 + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.bullet( + @ColorInt color: Int, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ) = singleSpan { + spanBullet(color, bulletRadius, gapWidth) + } + + /** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 圆形颜色 #RRGGBB #AARRGGBB + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ + fun Any?.bullet( + colorString: String, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int = 0, + ) = singleSpan { + spanBullet(colorString.color, bulletRadius, gapWidth) + } + + /** + * [AlignmentSpan] 设置段落对齐方式 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE] + */ + fun Any?.alignment( + align: Layout.Alignment + ) = singleSpan { + spanAlignment(align) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 背景颜色 + */ + fun Any?.lineBackground( + @ColorInt color: Int, + ) = singleSpan { + spanLineBackground(color) + } + + /** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param colorString 背景颜色 #RRGGBB #AARRGGBB + */ + fun Any?.lineBackground( + colorString: String, + ) = singleSpan { + spanLineBackground(colorString.color) + } + + /** + * [LeadingMarginSpan] 设置段落文本缩进 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param firstLines 首行行数. 与[firstMargin]关联 + * @param firstMargin 首行左边距(缩进) + * @param restMargin 剩余行(非首行)左边距(缩进) + */ + fun Any?.leadingMargin( + @IntRange(from = 1L) firstLines: Int, + @Px firstMargin: Int, + @Px restMargin: Int = 0 + ) = singleSpan { + spanLeadingMargin(firstLines, firstMargin, restMargin) + } + + /** + * [LineHeightSpan] 设置段落行高 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param height 行高 + */ + fun Any?.lineHeight( + @Px @IntRange(from = 1L) height: Int + ) = singleSpan { + spanLineHeight(height) + } + + /** + * [ParagraphBitmapSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param bitmap [Bitmap] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + fun Any?.imageParagraph( + bitmap: Bitmap, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ) = singleSpan { + spanImageParagraph(bitmap, padding, useTextViewSize, size) + } + + /** + * [ParagraphDrawableSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param drawable [Drawable] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ + fun Any?.imageParagraph( + drawable: Drawable, + @Px padding: Int = 0, + useTextViewSize: TextView? = null, + size: DrawableSize? = null + ) = singleSpan { + spanImageParagraph(drawable, padding, useTextViewSize, size) + } + + /** + * 自定义字符样式 + * + * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ + fun Any?.custom( + style: T, + replaceRule: Any? = null, + ) = singleSpan { + spanCustom(style, replaceRule) + } + + /** + * 自定义段落样式 + * + * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效 + */ + fun Any?.custom( + style: T, + replaceRule: Any? = null, + ) = singleSpan { + spanCustom(style, replaceRule) + } + + companion object { + /** + * @see [SpanDsl] + */ + fun create(text: CharSequence?, replaceRule: Any?): SpanDsl = + SpanDsl(text, replaceRule) + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt new file mode 100644 index 0000000..233b996 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpanInternal.kt @@ -0,0 +1,826 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("SpanInternal") +@file:Suppress("unused") + +package com.remax.visualnovel.utils.spannablex + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.BlurMaskFilter +import android.graphics.MaskFilter +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.style.* +import android.widget.TextView +import androidx.annotation.* +import androidx.annotation.IntRange +import com.bumptech.glide.request.RequestOptions +import com.drake.spannable.replaceSpan +import com.drake.spannable.setSpan +import com.drake.spannable.span.CenterImageSpan +import com.drake.spannable.span.GlideImageSpan +import com.drake.spannable.span.MarginSpan +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle +import com.remax.visualnovel.utils.spannablex.interfaces.OnSpanClickListener +import com.remax.visualnovel.utils.spannablex.span.LeadingMarginSpan +import com.remax.visualnovel.utils.spannablex.span.ParagraphBitmapSpan +import com.remax.visualnovel.utils.spannablex.span.ParagraphDrawableSpan +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableConfig +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyBulletSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyLineBackgroundSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyLineHeightSpan +import com.remax.visualnovel.utils.spannablex.span.legacy.LegacyQuoteSpan +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize +import com.remax.visualnovel.utils.spannablex.utils.drawableSize +import com.remax.visualnovel.utils.spannablex.utils.textSizeInt +import java.util.* + +// +/** + * ImageSpan Text标识 + */ +internal const val IMAGE_SPAN_TAG = " " + +private const val UNKNOWN_REPLACE_RULES = + "Unknown replace rules. please use `String(list/array)`, `Regex(list/array)`, `ReplaceRule(list/array)`." + +/** + * [CenterImageSpan] 适配 [Drawable] size + */ +private fun CenterImageSpan.setupSize( + useTextViewSize: TextView?, + size: DrawableSize? +): CenterImageSpan = apply { + useTextViewSize?.textSizeInt?.let { textSize -> + setDrawableSize(textSize, textSize) + } ?: size?.let { drawableSize -> + setDrawableSize(drawableSize.width, drawableSize.height) + } +} + +/** + * [CenterImageSpan] 适配 [Drawable] margin + * 这里多做判断,是防止[CenterImageSpan.setMarginHorizontal] 做多余的`drawableRef?.clear()` + */ +private fun CenterImageSpan.setupMarginHorizontal( + left: Int?, + right: Int? +): CenterImageSpan = apply { + if (left != null || right != null) { + setMarginHorizontal(left ?: 0, right ?: 0) + } +} + +/** + * [GlideImageSpan] 适配 [Drawable] size + */ +private fun GlideImageSpan.setupSize( + useTextViewSize: TextView?, + size: DrawableSize? +): GlideImageSpan = apply { + useTextViewSize?.textSizeInt?.let { textSize -> + setDrawableSize(textSize, textSize) + } ?: size?.let { drawableSize -> + setDrawableSize(drawableSize.width, drawableSize.height) + } +} + +/** + * [GlideImageSpan] 适配 [Drawable] margin + * 这里多做判断,是防止[GlideImageSpan.setMarginHorizontal] 做多余的`drawableRef?.set(null)` + */ +private fun GlideImageSpan.setupMarginHorizontal( + left: Int?, + right: Int? +): GlideImageSpan = apply { + if (left != null || right != null) { + setMarginHorizontal(left ?: 0, right ?: 0) + } +} + +/** + * 适配[setSpan] 的返回值为 [Spannable], 以便进行plus操作 + */ +private fun CharSequence.span(what: Any?): Spannable = setSpan(what) as Spannable + +/** + * 适配[replaceSpan] 的返回值为 [Spannable], 以便进行plus操作 + */ +private fun CharSequence.spanReplace( + regex: Regex, + quoteGroup: Boolean = false, + startIndex: Int = 0, + replacement: (MatchResult) -> Any? +): Spannable { + return (replaceSpan(regex, quoteGroup, startIndex, replacement = replacement) as? Spannable) ?: SpannableStringBuilder(this) +} + +/** + * 正则 [Regex] 列表替换 + */ +private fun CharSequence.replaceRegexList( + ruleList: List, + createWhat: (matchText: String) -> Any +): Spannable? { + var span: CharSequence? = null + ruleList.forEach { replace -> + span = (span ?: this).spanReplace(replace) { + createWhat.invoke(it.value) + } + } + return if (span is Spannable) span as Spannable else SpannableString.valueOf(span) +} + +/** + * 组合替换规则 [ReplaceRule] 列表替换 + */ +private fun CharSequence.replaceReplaceRuleList( + ruleList: List, + createWhat: (matchText: String) -> Any +): Spannable? { + var span: CharSequence? = null + ruleList.forEach { replace -> + var currentMatchCount = 0 + span = (span ?: this).spanReplace(replace.replaceRules) { + if (replace.matchRange == null || currentMatchCount++ in replace.matchRange) { + replace.replacementMatch?.onMatch(it) + val characterStyle = createWhat.invoke(it.value) + replace.newString?.span(characterStyle) ?: characterStyle + } else null + } + } + + return if (span is Spannable) span as Spannable else SpannableString.valueOf(span) +} + +/** + * [setSpan] or [replaceRule] + */ +@Suppress("UNCHECKED_CAST") +private fun CharSequence.setOrReplaceSpan( + replaceRule: Any?, + createWhat: (matchText: String) -> Any +): Spannable = replaceRule?.let { rule -> + when (rule) { + // + is String -> spanReplace(Regex.escape(rule).toRegex()) { + createWhat.invoke(it.value) + } + + is Regex -> spanReplace(rule) { + createWhat.invoke(it.value) + } + + is ReplaceRule -> replaceReplaceRuleList(listOf(rule), createWhat) + // + + // + is Array<*> -> if (rule.isEmpty()) { + span(createWhat.invoke(this.toString())) + } else { + when (rule[0]) { + /* String */ + is String -> replaceRegexList( + (rule as Array).map { Regex.escape(it).toRegex() }, + createWhat + ) + /* 正则 */ + is Regex -> replaceRegexList( + (rule as Array).toList(), + createWhat + ) + + /* ReplaceRule */ + is ReplaceRule -> replaceReplaceRuleList( + (rule as Array).toList(), + createWhat + ) + + else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES) + } + } + // + + // + is List<*> -> if (rule.isEmpty()) { + span(createWhat.invoke(this.toString())) + } else { + when (rule[0]) { + /* String */ + is String -> replaceRegexList( + (rule as List).map { Regex.escape(it).toRegex() }, + createWhat + ) + /* 正则 */ + is Regex -> replaceRegexList( + (rule as List), + createWhat + ) + /* ReplaceRule */ + is ReplaceRule -> replaceReplaceRuleList( + (rule as List), + createWhat + ) + + else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES) + } + } + // + else -> throw IllegalArgumentException(UNKNOWN_REPLACE_RULES) + } +} ?: span(createWhat.invoke(this.toString())) + +// + +// +/** + * [StyleSpan] 设置文本样式 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanStyle( + @TextStyle style: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + StyleSpan(style) +} + +/** + * [TypefaceSpan] 设置字体样式 + * + * @param typeface 字体(API>=28) + * @param family 字体集 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanTypeface( + typeface: Typeface?, + family: String?, + replaceRule: Any? +): Spannable = (if (typeface != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + TypefaceSpan(typeface) +} else TypefaceSpan(family)).let { typefaceSpan -> + setOrReplaceSpan(replaceRule) { typefaceSpan } +} + +/** + * [TextAppearanceSpan] 设置字体效果 + * + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param size 文本大小 + * @param color 文本颜色 + * @param family 字体集 + * @param linkColor 链接颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanTextAppearance( + @TextStyle style: Int = Typeface.NORMAL, + @Px size: Int = -1, + @ColorInt color: Int?, + family: String?, + linkColor: ColorStateList?, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + TextAppearanceSpan(family, style, size, color?.let(ColorStateList::valueOf), linkColor) +} + +/** + * [ForegroundColorSpan] 文本颜色 + * + * @param color 文本颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanColor( + @ColorInt color: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + ForegroundColorSpan(color) +} + +/** + * [BackgroundColorSpan] 背景颜色 + * + * @param color 背景颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanBackground( + @ColorInt color: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + BackgroundColorSpan(color) +} + +/** + * [CenterImageSpan] 图片 + * + * @param drawable [Drawable] + * @param source [Drawable] Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + drawable: Drawable, + source: String?, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + (source?.let { + CenterImageSpan(drawable, it) + } ?: CenterImageSpan(drawable)).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param uri 图片 Uri + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + context: Context, + uri: Uri, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + CenterImageSpan(context, uri).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param resourceId 图片Id + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + context: Context, + @DrawableRes resourceId: Int, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + CenterImageSpan(context, resourceId).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [CenterImageSpan] 图片 + * + * @param context [Context] + * @param bitmap [Bitmap] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanImage( + context: Context, + bitmap: Bitmap, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: CenterImageSpan.Align, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + CenterImageSpan(context, bitmap).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) +} + +/** + * [GlideImageSpan] 图片 + * + * @param view 当前Span所在的[TextView], 用于异步加载完图片后通知[TextView]刷新 + * @param url 图片地址参见 [Glide.with(view).load(url)] + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + * @param marginLeft 图片左边距 + * @param marginRight 图片右边距 + * @param align 图片对齐方式 [CenterImageSpan.Align.CENTER] [CenterImageSpan.Align.BOTTOM] [CenterImageSpan.Align.BASELINE] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanGlide( + view: TextView, + url: Any, + useTextViewSize: TextView?, + size: DrawableSize?, + @Px marginLeft: Int?, + @Px marginRight: Int?, + align: GlideImageSpan.Align, + loopCount: Int?, + requestOption: RequestOptions?, + replaceRule: Any?, +): Spannable = setOrReplaceSpan(replaceRule) { + GlideImageSpan(view, url).setupSize(useTextViewSize, size) + .setupMarginHorizontal(marginLeft, marginRight) + .setAlign(align) + .apply { + loopCount?.let(::setLoopCount) + requestOption?.let(::setRequestOption) + } +} + +/** + * [ScaleXSpan] X轴文本缩放 + * + * @param proportion 水平(X轴)缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanScaleX( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + ScaleXSpan(proportion) +} + +/** + * [MaskFilterSpan] 设置文本蒙版效果 + * + * @param filter 蒙版效果 [MaskFilter] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanMaskFilter( + filter: MaskFilter, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + MaskFilterSpan(filter) +} + +/** + * [BlurMaskFilter] 设置文本模糊滤镜蒙版效果 + * + * @param radius 模糊半径 + * @param style 模糊效果 [BlurMaskFilter.Blur] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanBlurMask( + @FloatRange(from = 0.0) radius: Float, + style: BlurMaskFilter.Blur?, + replaceRule: Any? +): Spannable = + spanMaskFilter(BlurMaskFilter(radius, style ?: BlurMaskFilter.Blur.NORMAL), replaceRule) + +/** + * [SuperscriptSpan] 设置文本为上标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanSuperscript( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + SuperscriptSpan() +} + +/** + * [SubscriptSpan] 设置文本为下标 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanSubscript( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + SubscriptSpan() +} + +/** + * [AbsoluteSizeSpan] 设置文本绝对大小 + * + * @param size 文本大小 + * @param dp true = [size] dp, false = [size] px + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanAbsoluteSize( + size: Int, + dp: Boolean, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + AbsoluteSizeSpan(size, dp) +} + +/** + * [RelativeSizeSpan] 设置文本相对大小 + * + * @param proportion 文本缩放比例 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanRelativeSize( + @FloatRange(from = 0.0) proportion: Float, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + RelativeSizeSpan(proportion) +} + +/** + * [StrikethroughSpan] 设置文本删除线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanStrikethrough( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + StrikethroughSpan() +} + +/** + * [UnderlineSpan] 设置文本下划线 + * + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanUnderline( + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + UnderlineSpan() +} + +/** + * [URLSpan] 设置文本超链接 + * + * 需配合[TextView.activateClick]使用 + * @param url 超链接地址 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanURL( + url: String, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + URLSpan(url) +} + +/** + * [SuggestionSpan] 设置文本输入提示 + * + * @param context [Context] + * @param suggestions 提示规则文本数组 + * @param flags 提示规则 [SuggestionSpan.FLAG_EASY_CORRECT] [SuggestionSpan.FLAG_MISSPELLED] [SuggestionSpan.FLAG_AUTO_CORRECTION] + * @param locale 语言区域设置 + * @param notificationTargetClass 通知目标. 基本已废弃, 只在API<29时生效 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanSuggestion( + context: Context, + suggestions: Array, + flags: Int, + locale: Locale?, + notificationTargetClass: Class<*>?, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + SuggestionSpan(context, locale, suggestions, flags, notificationTargetClass) +} + +/** + * [SimpleClickableSpan] 设置文本点击效果 + * + * @param color 文本颜色 + * @param backgroundColor 背景颜色 + * @param style 文本样式 [Typeface.NORMAL] [Typeface.BOLD] [Typeface.ITALIC] [Typeface.BOLD_ITALIC] + * @param config 附加配置 [SimpleClickableConfig] + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * @param onClick [OnSpanClickListener] 点击回调 + */ +internal fun CharSequence.spanClickable( + @ColorInt color: Int?, + @ColorInt backgroundColor: Int?, + @TextStyle style: Int?, + config: SimpleClickableConfig?, + replaceRule: Any?, + onClick: OnSpanClickListener? +): Spannable = setOrReplaceSpan(replaceRule) { matchText -> + SimpleClickableSpan(color, backgroundColor, style, config) { + onClick?.onClick(it, matchText) + } +} + +/** + * [MarginSpan] 设置文本间距 + * + * @param width 文本间距 + * @param color 间距填充颜色 + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanMargin( + @Px width: Int, + @ColorInt color: Int, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + MarginSpan(width, color) +} + +/** + * [QuoteSpan] 设置段落引用样式(段落前竖线标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 竖线颜色 + * @param stripeWidth 竖线宽度 + * @param gapWidth 竖线与文本之间间隔宽度 + */ +internal fun CharSequence.spanQuote( + @ColorInt color: Int, + @Px @IntRange(from = 0) stripeWidth: Int, + @Px @IntRange(from = 0) gapWidth: Int, +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + QuoteSpan(color, stripeWidth, gapWidth) + } else { + LegacyQuoteSpan(color, stripeWidth, gapWidth) + } +} + +/** + * [BulletSpan] 设置段落项目符号(段落前圆形标识) + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 圆形颜色 + * @param bulletRadius 圆形半径 + * @param gapWidth 竖线与文本之间间隔宽度 + */ +internal fun CharSequence.spanBullet( + @ColorInt color: Int, + @Px @IntRange(from = 0) bulletRadius: Int, + @Px gapWidth: Int, +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + BulletSpan(gapWidth, color, bulletRadius) + } else { + LegacyBulletSpan(color, bulletRadius, gapWidth) + } +} + +/** + * [AlignmentSpan] 设置段落对齐方式 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param align [Layout.Alignment.ALIGN_NORMAL] [Layout.Alignment.ALIGN_CENTER] [Layout.Alignment.ALIGN_OPPOSITE] + */ +internal fun CharSequence.spanAlignment( + align: Layout.Alignment +): Spannable = setOrReplaceSpan(null) { + AlignmentSpan.Standard(align) +} + +/** + * [LineBackgroundSpan] 设置段落背景颜色 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param color 背景颜色 + */ +internal fun CharSequence.spanLineBackground( + @ColorInt color: Int +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LineBackgroundSpan.Standard(color) + } else { + LegacyLineBackgroundSpan(color) + } +} + +/** + * [LeadingMarginSpan] 设置段落文本缩进 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param firstLines 首行行数. 与[firstMargin]关联 + * @param firstMargin 首行左边距(缩进) + * @param restMargin 剩余行(非首行)左边距(缩进) + */ +internal fun CharSequence.spanLeadingMargin( + @IntRange(from = 1L) firstLines: Int, + @Px firstMargin: Int, + @Px restMargin: Int +): Spannable = setOrReplaceSpan(null) { + LeadingMarginSpan(firstLines, firstMargin, restMargin) +} + +/** + * [LineHeightSpan] 设置段落行高 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param height 行高 + */ +internal fun CharSequence.spanLineHeight( + @Px @IntRange(from = 1L) height: Int +): Spannable = setOrReplaceSpan(null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + LineHeightSpan.Standard(height) + } else { + LegacyLineHeightSpan(height) + } +} + +/** + * [ParagraphBitmapSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param bitmap [Bitmap] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ +internal fun CharSequence.spanImageParagraph( + bitmap: Bitmap, + @Px padding: Int, + useTextViewSize: TextView?, + size: DrawableSize? +): Spannable = setOrReplaceSpan(null) { + ParagraphBitmapSpan(bitmap, useTextViewSize?.textSizeInt?.drawableSize ?: size, padding) +} + +/** + * [ParagraphDrawableSpan] 设置段落图片 + * + * [ParagraphStyle] 段落Style不支持文本替换 + * @param drawable [Drawable] + * @param padding 图片与文本的间距 + * @param useTextViewSize 图片使用指定的[TextView]文本大小,与参数[size]冲突,优先使用[useTextViewSize] + * @param size 图片大小 [DrawableSize] + */ +internal fun CharSequence.spanImageParagraph( + drawable: Drawable, + @Px padding: Int, + useTextViewSize: TextView?, + size: DrawableSize? +): Spannable = setOrReplaceSpan(null) { + ParagraphDrawableSpan(drawable, useTextViewSize?.textSizeInt?.drawableSize ?: size, padding) +} + +/** + * 自定义字符样式 + * + * @param style 自定义样式. eg. spanCustom(ForegroundColorSpan(Color.RED)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + */ +internal fun CharSequence.spanCustom( + style: T, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + style +} + +/** + * 自定义段落样式 + * + * @param style 自定义样式. eg. spanCustom(LineBackgroundSpan.Standard(Color.Red)) + * @param replaceRule 组合替换规则 [String] [Regex] [ReplaceRule] + * 由于段落样式的特殊性, [ParagraphStyle] 段落样式下 [replaceRule] 大部分情况并不会生效 + */ +internal fun CharSequence.spanCustom( + style: T, + replaceRule: Any? +): Spannable = setOrReplaceSpan(replaceRule) { + style +} + +// \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt new file mode 100644 index 0000000..cdb4865 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/SpannableX.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex + +import android.R +import android.app.Activity +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.SpannedString +import android.text.method.LinkMovementMethod +import android.text.style.CharacterStyle +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.drake.spannable.movement.ClickableMovementMethod + +/** + * 构建Spannable + * @see [SpanDsl] + */ +fun Any?.spannable(builderAction: SpanDsl.() -> Unit): SpannableStringBuilder = + SpanDsl.create( + text = if (this is CharSequence) this else null, replaceRule = null + ).apply(builderAction).spannable() + +// +/** + * 删除指定Span + */ +inline fun CharSequence.removeSpans(): CharSequence = + (if (this is Spannable) this else SpannableString(this)).apply { + val allSpans = getSpans(0, length, T::class.java) + for (span in allSpans) { + removeSpan(span) + } + } + +/** + * 删除所有[CharacterStyle] Span + */ +fun CharSequence.removeAllSpans(): CharSequence = + (if (this is Spannable) this else SpannableString(this)).apply { + val allSpans = getSpans(0, length, CharacterStyle::class.java) + for (span in allSpans) { + removeSpan(span) + } + } + +// + +// +/** + * 配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + */ +fun TextView.activateClick(background: Boolean = true): TextView = apply { + movementMethod = if (background) LinkMovementMethod.getInstance() else ClickableMovementMethod.getInstance() +} + +/** + * 循环获取控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun View?.autoActivateClick(background: Boolean, @IdRes vararg ignoreId: Int) { + when (this) { + is TextView -> { + if (!ignoreId.contains(id)) { + activateClick(background) + } + } + + is ViewGroup -> { + children.forEach { + it.autoActivateClick(background, *ignoreId) + } + } + } +} + +/** + * 循环 [ViewBinding] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun ViewBinding.activateAllTextViewClick( + background: Boolean = true, + @IdRes vararg ignoreId: Int +) { + root.autoActivateClick(background, *ignoreId) +} + +/** + * 循环 [Activity] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun Activity.activateAllTextViewClick(background: Boolean = true, @IdRes vararg ignoreId: Int) { + findViewById(R.id.content).children.first() + .autoActivateClick(background, *ignoreId) +} + +/** + * 循环 [Fragment] 控件并配置 [LinkMovementMethod] 或 [ClickableMovementMethod] + * @param background 是否显示点击背景 + * @param ignoreId 忽略配置movementMethod的ViewId + */ +fun Fragment.activateAllTextViewClick(background: Boolean = true, @IdRes vararg ignoreId: Int) { + view.autoActivateClick(background, *ignoreId) +} +// + +// +/** + * [String] 转为 [Spannable], 以便进行plus操作 + */ +val String.span: SpannedString + get() = SpannedString(this) + +/** + * 扩展Spanned +, 保留样式 + * operator [Spannable] + [CharSequence] + * @return [Spannable] + */ +operator fun Spanned.plus(other: CharSequence): SpannableStringBuilder = + when (this) { + is SpannableStringBuilder -> append(other) + else -> SpannableStringBuilder(this).append(other) + } + +// \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt new file mode 100644 index 0000000..ea8f6fc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/ConversionUnit.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.annotation + +import androidx.annotation.IntDef + +@IntDef(value = [ConversionUnit.NOT_CONVERT, ConversionUnit.SP, ConversionUnit.DP]) +@Retention(AnnotationRetention.SOURCE) +annotation class ConversionUnit { + + companion object { + /** + * 不转换单位 + */ + const val NOT_CONVERT = 0 + + /** + * 转换为sp + */ + const val SP = 1 + + /** + * 转换为dp + */ + const val DP = 2 + } +} + + + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt new file mode 100644 index 0000000..ec3aca6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/annotation/TextStyle.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.annotation + +import android.graphics.Typeface +import androidx.annotation.IntDef + +/** + * copy form [Typeface.Style], it's is SOURCE annotation + */ +@IntDef(value = [Typeface.NORMAL, Typeface.BOLD, Typeface.ITALIC, Typeface.BOLD_ITALIC]) +@Retention(AnnotationRetention.SOURCE) +annotation class TextStyle \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java new file mode 100644 index 0000000..3a6c844 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanClickListener.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.interfaces; + +import android.view.View; + +import com.remax.visualnovel.utils.spannablex.span.SimpleClickableSpan; + +/** + * {@link SimpleClickableSpan} 点击回调 + *

+ * {@link SpanInternal#spanClickable} + */ +public interface OnSpanClickListener { + /** + * {@link SimpleClickableSpan}被点击时回调 + * + * @param v 点击的当前View + * @param matchText 点击时匹配上的文本 + */ + void onClick(View v, String matchText); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java new file mode 100644 index 0000000..29fdb99 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/interfaces/OnSpanReplacementMatch.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.interfaces; + +import com.remax.visualnovel.utils.spannablex.ReplaceRule; + +import kotlin.jvm.functions.Function1; +import kotlin.text.MatchResult; +import kotlin.text.Regex; + +/** + * 当 {@link ReplaceRule} 有匹配项时回调 + * 详细说明: {@link com.drake.spannable.SpanUtilsKt#replaceSpan(CharSequence, Regex, Function1)} + */ +public interface OnSpanReplacementMatch { + /** + * @param result 当前 @{@link Regex} 匹配到的结果 + */ + void onMatch(MatchResult result); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt new file mode 100644 index 0000000..8e83ca8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/LeadingMarginSpan.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.style.LeadingMarginSpan +import androidx.annotation.IntRange +import androidx.annotation.Px + +class LeadingMarginSpan( + @IntRange(from = 1L) val firstLines: Int, + @Px val firstMargin: Int, + @Px val restMargin: Int, +) : LeadingMarginSpan.LeadingMarginSpan2 { + + override fun getLeadingMargin(first: Boolean): Int = if (first) firstMargin else restMargin + + override fun getLeadingMarginLineCount(): Int = firstLines + + override fun drawLeadingMargin( + c: Canvas?, p: Paint?, x: Int, dir: Int, top: Int, + baseline: Int, bottom: Int, text: CharSequence?, start: Int, end: Int, + first: Boolean, layout: Layout? + ) { + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt new file mode 100644 index 0000000..89eb4fc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphBitmapSpan.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import android.text.style.LineHeightSpan +import androidx.annotation.Px +import androidx.core.graphics.scale +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize + +class ParagraphBitmapSpan( + val bitmap: Bitmap, + private val drawableSize: DrawableSize?, + @Px val padding: Int +) : LeadingMarginSpan, LineHeightSpan { + private val bitmapHeight: Int + get() = drawableSize?.height ?: bitmap.height + + private val bitmapWidth: Int + get() = drawableSize?.width ?: bitmap.width + + override fun getLeadingMargin(first: Boolean): Int { + return bitmapWidth + padding + } + + override fun drawLeadingMargin( + c: Canvas, p: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout + ) { + val st = (text as Spanned).getSpanStart(this) + val lineTop = layout.getLineTop(layout.getLineForOffset(st)) + val scaleBitmap = drawableSize?.let { + bitmap.scale(bitmapWidth, bitmapHeight, true) + } ?: bitmap + + c.drawBitmap( + scaleBitmap, + (if (dir < 0) bitmapWidth - x else x).toFloat(), + lineTop.toFloat(), + p + ) + } + + override fun chooseHeight( + text: CharSequence, start: Int, end: Int, + istartv: Int, v: Int, + fm: FontMetricsInt + ) { + if (end == (text as Spanned).getSpanEnd(this)) { + val ht = bitmapHeight + var need = ht - (v + fm.descent - fm.ascent - istartv) + if (need > 0) { + fm.descent += need + } + need = ht - (v + fm.bottom - fm.top - istartv) + if (need > 0) { + fm.bottom += need + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt new file mode 100644 index 0000000..a09ef2c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/ParagraphDrawableSpan.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Paint.FontMetricsInt +import android.graphics.drawable.Drawable +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import android.text.style.LineHeightSpan +import androidx.annotation.Px +import com.remax.visualnovel.utils.spannablex.utils.DrawableSize + +class ParagraphDrawableSpan( + val drawable: Drawable, + private val drawableSize: DrawableSize?, + @Px val padding: Int +) : LeadingMarginSpan, LineHeightSpan { + private val drawableHeight: Int + get() = drawableSize?.height ?: drawable.intrinsicHeight + + private val drawableWidth: Int + get() = drawableSize?.width ?: drawable.intrinsicWidth + + override fun getLeadingMargin(first: Boolean): Int { + return drawableWidth + padding + } + + override fun drawLeadingMargin( + c: Canvas, p: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout + ) { + val st = (text as Spanned).getSpanStart(this) + val lineTop = layout.getLineTop(layout.getLineForOffset(st)) + drawable.setBounds(x, lineTop, x + drawableWidth, lineTop + drawableHeight) + drawable.draw(c) + } + + override fun chooseHeight( + text: CharSequence, start: Int, end: Int, + istartv: Int, v: Int, + fm: FontMetricsInt + ) { + if (end == (text as Spanned).getSpanEnd(this)) { + val ht = drawableHeight + var need = ht - (v + fm.descent - fm.ascent - istartv) + if (need > 0) { + fm.descent += need + } + need = ht - (v + fm.bottom - fm.top - istartv) + if (need > 0) { + fm.bottom += need + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt new file mode 100644 index 0000000..7ddf581 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/SimpleClickableSpan.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span + +import android.graphics.Color +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View +import androidx.annotation.ColorInt +import com.remax.visualnovel.utils.spannablex.annotation.TextStyle + +typealias OnSimpleClickListener = (widget: View) -> Unit + +data class SimpleClickableConfig( + + /** + * 下划线 + */ + val underline: Boolean? = null, +) + +class SimpleClickableSpan( + @ColorInt private val color: Int? = null, + @ColorInt private val backgroundColor: Int? = null, + @TextStyle private val typeStyle: Int? = null, + private val config: SimpleClickableConfig? = null, + private val onClick: OnSimpleClickListener? = null +) : ClickableSpan() { + + constructor( + colorString: String?, + backgroundColorString: String?, + @TextStyle typeStyle: Int? = null, + config: SimpleClickableConfig? = null, + onClick: OnSimpleClickListener? = null + ) : this( + colorString?.let(Color::parseColor), + backgroundColorString?.let(Color::parseColor), + typeStyle, + config, + onClick + ) + + override fun updateDrawState(ds: TextPaint) { + color?.let(ds::setColor) + backgroundColor?.let { ds.bgColor = backgroundColor } + typeStyle?.let(Typeface::defaultFromStyle)?.let(ds::setTypeface) + + config?.run { + underline?.let(ds::setUnderlineText) + } + } + + override fun onClick(widget: View) { + onClick?.invoke(widget) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt new file mode 100644 index 0000000..4ef9003 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyBulletSpan.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import androidx.annotation.ColorInt +import androidx.annotation.IntRange + +/** Copy @RequiresApi(Build.VERSION_CODES.P) Type from [android.text.style.BulletSpan] */ +class LegacyBulletSpan( + @ColorInt val color: Int, + @IntRange(from = 0) val bulletRadius: Int, + val gapWidth: Int +) : LeadingMarginSpan { + + override fun getLeadingMargin(first: Boolean): Int { + return 2 * bulletRadius + gapWidth + } + + override fun drawLeadingMargin( + canvas: Canvas, paint: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout? + ) { + if ((text as Spanned).getSpanStart(this) == start) { + val cacheStyle = paint.style + val cacheColor = paint.color + paint.color = color + paint.style = Paint.Style.FILL + val yPosition = (top + bottom) / 2f + val xPosition = (x + dir * bulletRadius).toFloat() + canvas.drawCircle(xPosition, yPosition, bulletRadius.toFloat(), paint) + paint.color = cacheColor + paint.style = cacheStyle + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt new file mode 100644 index 0000000..0a362f9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineBackgroundSpan.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.LineBackgroundSpan +import androidx.annotation.ColorInt +import androidx.annotation.Px + +/** Copy @RequiresApi(Build.VERSION_CODES.Q) Type from [LineBackgroundSpan.Standard] */ +class LegacyLineBackgroundSpan(@ColorInt val color: Int) : LineBackgroundSpan { + + override fun drawBackground( + canvas: Canvas, paint: Paint, + @Px left: Int, @Px right: Int, + @Px top: Int, @Px baseline: Int, @Px bottom: Int, + text: CharSequence, start: Int, end: Int, + lineNumber: Int + ) { + val originColor = paint.color + paint.color = color + canvas.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), paint) + paint.color = originColor + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt new file mode 100644 index 0000000..eca76cf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyLineHeightSpan.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Paint.FontMetricsInt +import android.text.style.LineHeightSpan +import androidx.annotation.IntRange +import androidx.annotation.Px +import kotlin.math.roundToInt + +/** Copy @RequiresApi(Build.VERSION_CODES.Q) Type from [LineHeightSpan.Standard] */ +class LegacyLineHeightSpan(@Px @IntRange(from = 1) val height: Int) : LineHeightSpan { + + override fun chooseHeight( + text: CharSequence, start: Int, end: Int, + spanstartv: Int, lineHeight: Int, + fm: FontMetricsInt + ) { + val originHeight = fm.descent - fm.ascent + if (originHeight <= 0) { + return + } + val ratio = height * 1.0f / originHeight + fm.descent = (fm.descent * ratio).roundToInt() + fm.ascent = fm.descent - height + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt new file mode 100644 index 0000000..81e770a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/span/legacy/LegacyQuoteSpan.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.span.legacy + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.style.LeadingMarginSpan +import androidx.annotation.ColorInt +import androidx.annotation.IntRange +import androidx.annotation.Px + +/** Copy @RequiresApi(Build.VERSION_CODES.P) Type from [android.text.style.QuoteSpan] */ +class LegacyQuoteSpan( + @ColorInt val color: Int, + @Px @IntRange(from = 0) val stripeWidth: Int, + @Px @IntRange(from = 0) val gapWidth: Int +) : LeadingMarginSpan { + + override fun getLeadingMargin(first: Boolean): Int { + return stripeWidth + gapWidth + } + + override fun drawLeadingMargin( + c: Canvas, p: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, layout: Layout + ) { + val cacheStyle = p.style + val cacheColor = p.color + + p.style = Paint.Style.FILL + p.color = color + c.drawRect( + x.toFloat(), + top.toFloat(), + (x + dir * stripeWidth).toFloat(), + bottom.toFloat(), + p + ) + + p.style = cacheStyle + p.color = cacheColor + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt new file mode 100644 index 0000000..f29527f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/DrawableSize.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.utils + +import android.graphics.drawable.Drawable +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.IntRange +import androidx.annotation.Keep +import com.drake.spannable.span.CenterImageSpan + +/** + * [CenterImageSpan] 的大小配置辅助类 + */ +@Keep +data class DrawableSize( + @IntRange(from = 0L) val width: Int, + @IntRange(from = 0L) val height: Int = width +) + +/** + * 快速构建 [DrawableSize] + */ +val Int.drawableSize: DrawableSize + get() = DrawableSize(this, this) + + +/** + * 设置[Drawable] 大小 + */ +fun Drawable.drawableSize(width: Int, height: Int = width): Drawable = apply { + setBounds(0, 0, width, height) +} + +/** + * 设置[Drawable] 大小为[TextView] [EditText] 的字体大小 + * @param view 参考文字大小的textSize view + */ +fun Drawable.configTextViewSize(view: T?): Drawable = apply { + view?.textSizeInt?.let { size -> + drawableSize(size, size) + } ?: drawableSize(intrinsicWidth, intrinsicHeight) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt new file mode 100644 index 0000000..fe63676 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/spannablex/utils/SpanUtils.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 TxcA, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.utils.spannablex.utils + +import android.content.res.Resources +import android.graphics.Color +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.graphics.toColorInt +import kotlin.math.roundToInt + +/** + * ColorString 2 [ColorInt] + * error default is [Color.RED] + */ +val String.color: Int + get() = try { + toColorInt() + } catch (e: Exception) { + Color.RED + } + +/** + * @receiver dp 2 px + */ +val Int.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).roundToInt() + +/** + * @receiver dp 2 px + */ +val Float.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density + 0.5f).roundToInt() + +/** + * @receiver sp 2 px + */ +val Int.sp: Int + get() = (this * Resources.getSystem().displayMetrics.scaledDensity + 0.5f).roundToInt() + +/** + * 获取Int型[TextView.getTextSize] + */ +val TextView.textSizeInt: Int + get() = textSize.roundToInt() diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt new file mode 100644 index 0000000..6f2df2c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/PriceView.kt @@ -0,0 +1,63 @@ +package com.remax.visualnovel.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.StringRes +import androidx.core.content.withStyledAttributes +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetPriceViewBinding +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.dylanc.viewbinding.nonreflection.inflate + +/** + * Created by HJW on 2023/8/2 + */ +@SuppressLint("SetTextI18n") +class PriceView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + LinearLayout(context, attrs, defStyleAttr) { + + private var binding: WidgetPriceViewBinding? = null + + init { + binding = inflate(WidgetPriceViewBinding::inflate) + context.withStyledAttributes(attrs, R.styleable.PriceView) { + val iconSize = getDimensionPixelOffset(R.styleable.PriceView_priceIconSize, 16.dp) + val priceIconPadding = getDimensionPixelOffset(R.styleable.PriceView_priceIconPadding, 4.dp) + + binding?.priceIcon?.run { + setMargin(marginEnd = priceIconPadding) + setSize(iconSize, iconSize) + } + + + getString(R.styleable.PriceView_priceTextToken)?.let { txtToken -> + binding?.priceTv?.changeTextFont { + textUITextToken = txtToken + } + } + + val content = getString(R.styleable.PriceView_priceText) + binding?.priceTv?.text = content + } + } + + fun getContentView() = binding?.priceTv + + fun setSizeType(@StringRes txtToken: Int, iconSize: Int) { + binding?.run { + priceTv.changeTextFont { + textUITextToken = context.getString(txtToken) + } + priceIcon.setSize(iconSize, iconSize) + } + } + + fun setPrice(content: String?) { + binding?.priceTv?.text = content + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt new file mode 100644 index 0000000..752f589 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagFlowLayout2.kt @@ -0,0 +1,333 @@ +package com.remax.visualnovel.widget.custom + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.text.TextUtils +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.remax.visualnovel.R +import kotlin.math.max +import kotlin.math.min + +class TagFlowLayout2 @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ViewGroup(context, attrs, defStyleAttr) { + + // 属性变量 + private var horizontalSpacing = 20f.dpToPx() + private var verticalSpacing = 12f.dpToPx() + private var textSize = 14f.spToPx() + private var textColor = Color.WHITE + private var tagBackground: Drawable? = null + private var maxLines = Int.MAX_VALUE + private var expandIndicator: Drawable? = null + private var collapseIndicator: Drawable? = null + private var eachLineMaxTagNum = 2 //一行最大标签宽度数 + + // 状态变量 + private var isExpanded = false + private var actualLineCount = 0 + private var showExpandButton = false + private var eachLineAvailableWidth = 0 // 可用宽度 + + // 数据 + private val tagItems = mutableListOf() + private val tagViews = mutableListOf() + private lateinit var expandButton: TextView + + // 监听器 + private var onTagClickListener: ((TagItem) -> Unit)? = null + private var onExpandStateChangeListener: ((Boolean) -> Unit)? = null + + init { + initAttributes(attrs) + initExpandButton() + } + + private fun initAttributes(attrs: AttributeSet?) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TagFlowLayout2) + + horizontalSpacing = typedArray.getDimension( + R.styleable.TagFlowLayout2_tag_horizontal_spacing, horizontalSpacing + ) + verticalSpacing = typedArray.getDimension( + R.styleable.TagFlowLayout2_tag_vertical_spacing, verticalSpacing + ) + textSize = typedArray.getDimension( + R.styleable.TagFlowLayout2_tag_text_size, textSize + ) + textColor = typedArray.getColor( + R.styleable.TagFlowLayout2_tag_text_color, textColor + ) + tagBackground = typedArray.getDrawable(R.styleable.TagFlowLayout2_tag_background) + maxLines = typedArray.getInt(R.styleable.TagFlowLayout2_tag_max_lines, Int.MAX_VALUE) + expandIndicator = typedArray.getDrawable(R.styleable.TagFlowLayout2_expand_indicator_drawable) + collapseIndicator = typedArray.getDrawable(R.styleable.TagFlowLayout2_collapse_indicator_drawable) + eachLineMaxTagNum = typedArray.getInt(R.styleable.TagFlowLayout2_each_line_max_num, 2) + typedArray.recycle() + + + if (tagBackground == null) { + tagBackground = createDefaultBackground() + } + if (expandIndicator == null) { + expandIndicator = ContextCompat.getDrawable(context, R.mipmap.tag_flow_expand) + } + if (collapseIndicator == null) { + collapseIndicator = ContextCompat.getDrawable(context, R.mipmap.tag_flow_shrink) + } + } + + private fun createDefaultBackground(): Drawable { + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = 16f.dpToPx() + gradientDrawable.setColor(Color.parseColor("#8A8A8E")) + return gradientDrawable + } + + private fun initExpandButton() { + expandButton = TextView(context).apply { + text = "展开" + setCompoundDrawablesWithIntrinsicBounds(null, null, expandIndicator, null) + compoundDrawablePadding = 4.dpToPx() + setTextColor(Color.parseColor("#8A8A8E")) + textSize = 12f + setPadding(12.dpToPx(), 6.dpToPx(), 8.dpToPx(), 6.dpToPx()) + background = createExpandButtonBackground() + + setOnClickListener { + toggleExpandState() + } + } + addView(expandButton) + } + + private fun createExpandButtonBackground(): Drawable { + val gradientDrawable = GradientDrawable() + gradientDrawable.cornerRadius = 16f.dpToPx() + gradientDrawable.setColor(Color.parseColor("#E5E5EA")) + gradientDrawable.setStroke(1.dpToPx(), Color.parseColor("#C6C6C8")) + return gradientDrawable + } + + // 设置标签数据 + fun setTags(tags: List) { + tagItems.clear() + tagViews.forEach { removeView(it) } + tagViews.clear() + + tagItems.addAll(tags) + + tags.forEach { tag -> + val textView = createTagView(tag) + tagViews.add(textView) + addView(textView) + } + + requestLayout() + } + + private fun createTagView(tag: TagItem): TextView { + return TextView(context).apply { + text = tag.text + setTextColor(textColor) + textSize = textSize / resources.displayMetrics.scaledDensity + setPadding(16.dpToPx(), 8.dpToPx(), 16.dpToPx(), 8.dpToPx()) + setBackgroundResource(R.drawable.tag_flow_item_bg) + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + maxLines = 1 + includeFontPadding = false + + setOnClickListener { + onTagClickListener?.invoke(tag) + } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + eachLineAvailableWidth = width - paddingLeft - paddingRight + + val oneTagMaxWidth = (eachLineAvailableWidth - (eachLineMaxTagNum-1) * horizontalSpacing) / eachLineMaxTagNum + var totalNeedHeight = 0 + if (tagViews.isEmpty()) { + setMeasuredDimension(width, totalNeedHeight) + return + } + + // 为所有标签应用最大宽度限制 + tagViews.forEach { subView -> + val maxWidthSpec = MeasureSpec.makeMeasureSpec(oneTagMaxWidth.toInt(), MeasureSpec.AT_MOST) + val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + subView.measure(maxWidthSpec, heightSpec) + } + + + var curLineTotalWidth = 0 + var curLineTotalHeight = 0 + var lineCount = 0 + val maxDisplayLines = if (isExpanded) Int.MAX_VALUE else maxLines + + tagViews.forEach { view -> + val childWidth = view.measuredWidth + val childHeight = view.measuredHeight + + // 检查是否需要换行(考虑水平间距) + val curLineNeedTotalWidth: Int = if (curLineTotalWidth == 0) childWidth + else curLineTotalWidth + horizontalSpacing.toInt() + childWidth + + if (curLineNeedTotalWidth > eachLineAvailableWidth) { + // 换行处理 + lineCount++ + if (lineCount >= maxDisplayLines) { + return@forEach + } + + totalNeedHeight += curLineTotalHeight + (if (lineCount >= 1) verticalSpacing.toInt() else 0) + curLineTotalWidth = childWidth + curLineTotalHeight = childHeight + } else { + curLineTotalWidth = curLineNeedTotalWidth + curLineTotalHeight = max(curLineTotalHeight, childHeight) + } + } + + // 添加最后一行高度 + if (lineCount < maxDisplayLines && tagViews.isNotEmpty()) { + totalNeedHeight += curLineTotalHeight + } + + // 添加padding + totalNeedHeight += paddingTop + paddingBottom + + actualLineCount = lineCount + 1 + showExpandButton = actualLineCount > maxLines && !isExpanded + + // 测量展开按钮 + if (showExpandButton) { + val buttonWidthSpec = MeasureSpec.makeMeasureSpec(eachLineAvailableWidth, MeasureSpec.AT_MOST) + val buttonHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + expandButton.measure(buttonWidthSpec, buttonHeightSpec) + } + + setMeasuredDimension(width, totalNeedHeight) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + if (tagViews.isEmpty()) return + + val width = r - l + val maxTagWidth = (eachLineAvailableWidth * eachLineMaxTagNum).toInt() + + var currentLeft = paddingLeft + var currentTop = paddingTop + var currentLineHeight = 0 + var lineCount = 0 + val maxDisplayLines = if (isExpanded) Int.MAX_VALUE else maxLines + + // 布局可见的标签 + for (i in tagViews.indices) { + val view = tagViews[i] + val childWidth = min(view.measuredWidth, maxTagWidth) + val childHeight = view.measuredHeight + + // 检查是否需要换行 + if (currentLeft + childWidth > width - paddingRight) { + lineCount++ + if (lineCount >= maxDisplayLines) { + view.visibility = GONE + continue + } + + currentLeft = paddingLeft + currentTop += currentLineHeight + verticalSpacing.toInt() + currentLineHeight = 0 + } + + view.visibility = VISIBLE + view.layout(currentLeft, currentTop, currentLeft + childWidth, currentTop + childHeight) + + currentLeft += childWidth + horizontalSpacing.toInt() + currentLineHeight = max(currentLineHeight, childHeight) + } + + // 布局展开按钮 + layoutExpandButton(width, currentTop, currentLineHeight) + } + + private fun layoutExpandButton(parentWidth: Int, currentTop: Int, currentLineHeight: Int) { + if (showExpandButton) { + expandButton.visibility = VISIBLE + val buttonWidth = expandButton.measuredWidth + val buttonHeight = expandButton.measuredHeight + + // 计算按钮位置(在当前行右侧) + val buttonLeft = parentWidth - paddingRight - buttonWidth + val buttonTop = currentTop + (currentLineHeight - buttonHeight) / 2 + + expandButton.layout( + buttonLeft, + buttonTop, + buttonLeft + buttonWidth, + buttonTop + buttonHeight + ) + } else { + expandButton.visibility = GONE + } + } + + private fun toggleExpandState() { + isExpanded = !isExpanded + updateExpandButton() + requestLayout() + onExpandStateChangeListener?.invoke(isExpanded) + } + + private fun updateExpandButton() { + val indicator = if (isExpanded) collapseIndicator else expandIndicator + expandButton.setCompoundDrawablesWithIntrinsicBounds(null, null, indicator, null) + expandButton.text = if (isExpanded) "收起" else "展开" + } + + // 公共方法 + fun setOnTagClickListener(listener: (TagItem) -> Unit) { + onTagClickListener = listener + } + + fun setOnExpandStateChangeListener(listener: (Boolean) -> Unit) { + onExpandStateChangeListener = listener + } + + fun expand() { + if (!isExpanded) { + toggleExpandState() + } + } + + fun collapse() { + if (isExpanded) { + toggleExpandState() + } + } + + fun isExpanded(): Boolean = isExpanded + + // 设置最大标签数 + fun setMaxTagsNumEachLine(maxTagNumForEachLine: Int) { + require(maxTagNumForEachLine >= 1 && maxTagNumForEachLine <= 10) { "Ratio must be between 1 and 10" } + eachLineMaxTagNum = maxTagNumForEachLine + requestLayout() + } + + // 扩展函数 + private fun Float.dpToPx(): Float = this * resources.displayMetrics.density + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt() + private fun Float.spToPx(): Float = this * resources.displayMetrics.scaledDensity +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt new file mode 100644 index 0000000..6afff85 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/custom/TagItem.kt @@ -0,0 +1,7 @@ +package com.remax.visualnovel.widget.custom + +data class TagItem( + val id: String, + val text: String, + var isSelected: Boolean = false +) \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt new file mode 100644 index 0000000..c558236 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/LBindingDialog.kt @@ -0,0 +1,444 @@ +package com.remax.visualnovel.widget.dialoglib + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape +import android.os.Bundle +import android.util.SparseArray +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.ImageView +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken + +/** + * Created by HJW on 2021/10/20 + */ +open class LBindingDialog(private val context: Context, + private val inflate: (LayoutInflater) -> VB, + themeResId: Int = R.style.LDialog) : + Dialog(context, themeResId), LifecycleEventObserver { + private val views = SparseArray() + private var width = 0 + private var height = 0 + private var bgRadius = 0 //背景圆角 + + private var leftTopRadius = 0 + private var rightTopRadius = 0 + private var leftBottomRadius = 0 + private var rightBottomRadius = 0 + private var bgColor = Color.TRANSPARENT //背景颜色 + + lateinit var binding: VB + var currEvent: Lifecycle.Event? = null + + companion object { + + fun getRoundRectDrawable(radius: Int, color: Int): ShapeDrawable { + return getRoundRectDrawable(radius, radius, radius, radius, color) + } + + fun getRoundRectDrawable(leftTop: Int, rightTop: Int, rightBottom: Int, leftBottom: Int, color: Int): ShapeDrawable { + //左上、右上、右下、左下的圆角半径 + val radius = floatArrayOf( + leftTop.toFloat(), + leftTop.toFloat(), + rightTop.toFloat(), + rightTop.toFloat(), + rightBottom.toFloat(), + rightBottom.toFloat(), + leftBottom.toFloat(), + leftBottom.toFloat() + ) + val drawable = ShapeDrawable().apply { + shape = RoundRectShape(radius, null, null) + paint.color = color + } + return drawable + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = inflate(layoutInflater) + setContentView(binding.root) + init() + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + currEvent = event + if (Lifecycle.Event.ON_DESTROY == event && isShowing) { + dismiss() + } + } + + fun init() { + if (context is ComponentActivity) { + context.lifecycle.addObserver(this) + } + setCanceledOnTouchOutside(true) + window?.setBackgroundDrawableResource(R.color.transparent) + width = (ScreenUtils.getScreenWidth() * 0.8).toInt() + height = WindowManager.LayoutParams.WRAP_CONTENT + setWidthHeight() + window?.setWindowAnimations(R.style.dialog_alpha) + } + + fun with(): LBindingDialog { + create() + setBgColorToken(R.string.color_surface_base_normal) + return this + } + + fun setAnimationsStyle(style: Int): LBindingDialog { + window?.setWindowAnimations(style) + return this + } + + /** + * 设置位置 + */ + fun setGravity(gravity: Int, offX: Int, offY: Int): LBindingDialog { + setGravity(gravity) + val layoutParams = window?.attributes + layoutParams?.x = offX + layoutParams?.y = offY + window?.attributes = layoutParams + return this + } + + fun setGravity(gravity: Int): LBindingDialog { + window?.setGravity(gravity) + return this + } + + + fun setBottom(): LBindingDialog { + setGravity(Gravity.BOTTOM) + setAnimationsStyle(R.style.dialog_translate) + setWidthRatio(1.0) + setDBgRadius(24, 24, 0, 0) + return this + } + + fun setCenter(cancelable: Boolean = true): LBindingDialog { + setGravity(Gravity.CENTER) + setWidthRatio(0.8) + setBgRadius(16) + setAnimationsStyle(R.style.dialog_alpha) + setCanceledOnTouchOutside(cancelable) + setCancelable(cancelable) + return this + } + + override fun show() { + if (!isShowing) { + super.show() + } + } + + fun thisShow(): DialogInterface { + this.show() + return this + } + + /** + * 遮罩透明度 + * + * @param value 0-1f + */ + fun setMaskValue(value: Float): LBindingDialog { + window?.setDimAmount(value) + return this + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置背景>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + open fun setBg(): LBindingDialog { + if (leftTopRadius != 0 || rightTopRadius != 0 || rightBottomRadius != 0 || leftBottomRadius != 0) { + window?.setBackgroundDrawable(getRoundRectDrawable(leftTopRadius, rightTopRadius, rightBottomRadius, leftBottomRadius, bgColor)) + } else { + window?.setBackgroundDrawable(getRoundRectDrawable(bgRadius, bgColor)) + } + return this + } + + /** + * 设置背景颜色 + */ + fun setBgColor(@ColorInt color: Int): LBindingDialog { + bgColor = color + return setBg() + } + + fun setBgColorToken(@StringRes colorToken: Int): LBindingDialog { + bgColor = context.handleUIToken(colorToken)?.color ?: 0 + return setBg() + } + + fun setBgColorRes(colorRes: Int): LBindingDialog { + bgColor = ContextCompat.getColor(context, colorRes) + return setBg() + } + + /** + * 设置背景圆角 + */ + fun setBgRadius(bgRadius: Int): LBindingDialog { + this.bgRadius = bgRadius.dp + return setBg() + } + + /** + * 设置背景不同圆角 + */ + fun setDBgRadius(leftTopRadius: Int, rightTopRadius: Int, rightBottomRadius: Int, leftBottomRadius: Int): LBindingDialog { + this.leftTopRadius = leftTopRadius.dp + this.rightTopRadius = rightTopRadius.dp + this.rightBottomRadius = rightBottomRadius.dp + this.leftBottomRadius = leftBottomRadius.dp + return setBg() + } + + /** + * 设置背景圆角 + */ + fun setBgRadiusPX(bgRadius: Int): LBindingDialog { + this.bgRadius = bgRadius + return setBg() + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置宽高>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + /** + * 设置宽高 + */ + open fun setWidthHeight(): LBindingDialog { + val dialogWindow = window + val lp = dialogWindow?.attributes + lp?.width = width + lp?.height = height + dialogWindow?.attributes = lp + return this + } + + fun setWidth(width: Int): LBindingDialog { + this.width = width.dp + return setWidthHeight() + } + + fun setWidthPX(width: Int): LBindingDialog { + this.width = width + return setWidthHeight() + } + + fun setHeight(height: Int): LBindingDialog { + this.height = height.dp + return setWidthHeight() + } + + fun setHeightPX(height: Int): LBindingDialog { + this.height = height + return setWidthHeight() + } + + /** + * 设置宽占屏幕的比例 + */ + fun setWidthRatio(widthRatio: Double): LBindingDialog { + width = (ScreenUtils.getScreenWidth() * widthRatio).toInt() + setWidthHeight() + return this + } + + /** + * 设置高占屏幕的比例 + */ + fun setHeightRatio(heightRatio: Double): LBindingDialog { + height = (ScreenUtils.getHeightRealPixels() * heightRatio).toInt() + setWidthHeight() + return this + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置监听>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + /** + * 设置监听 + */ + open fun setOnClickListener(onClickListener: DialogOnClickListener, vararg viewIds: Int): LBindingDialog { + val lDialog: LBindingDialog = this + for (element in viewIds) { + getView(element).setOnClickListener { v -> onClickListener.onClick(v, lDialog) } + } + return this + } + + interface DialogOnClickListener { + fun onClick(v: View, lDialog: LBindingDialog<*>) + } + + /** + * 设置 关闭dialog的按钮 + */ + fun setCancelBtn(viewId: Int): LBindingDialog { + getView(viewId)?.setOnClickListener(View.OnClickListener { dismiss() }) + return this + } + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>设置常见属性>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + @Suppress("UNCHECKED_CAST") + fun getView(@IdRes viewId: Int): T { + var view = views[viewId] + if (view == null) { + view = binding.root.findViewById(viewId) + views[viewId] = view + } + return view as T + } + + /** + * Will set the text of a TextView. + * + * @param viewId The view id. + * @param value The text to put in the text view. + * @return The BaseViewHolder for chaining. + */ + fun setText(@IdRes viewId: Int, value: CharSequence): LBindingDialog { + val view = getView(viewId) + view.text = value + return this + } + + fun setText(@IdRes viewId: Int, @StringRes strId: Int): LBindingDialog { + val view = getView(viewId) + view.setText(strId) + return this + } + + /** + * Will set the image of an ImageView from a resource id. + * + * @param viewId The view id. + * @param imageResId The image resource id. + * @return The BaseViewHolder for chaining. + */ + fun setImageResource(@IdRes viewId: Int, @DrawableRes imageResId: Int): LBindingDialog { + val view = getView(viewId) + view.setImageResource(imageResId) + return this + } + + /** + * Will set background color of a view. + * + * @param viewId The view id. + * @param color A color, not a resource id. + * @return The BaseViewHolder for chaining. + */ + fun setBackgroundColor(@IdRes viewId: Int, @ColorInt color: Int): LBindingDialog { + val view = getView(viewId) + view.setBackgroundColor(color) + return this + } + + /** + * Will set background of a view. + * + * @param viewId The view id. + * @param backgroundRes A resource to use as a background. + * @return The BaseViewHolder for chaining. + */ + fun setBackgroundRes(@IdRes viewId: Int, @DrawableRes backgroundRes: Int): LBindingDialog { + val view = getView(viewId) + view.setBackgroundResource(backgroundRes) + return this + } + + /** + * Will set text color of a TextView. + * + * @param viewId The view id. + * @param textColor The text color (not a resource id). + * @return The BaseViewHolder for chaining. + */ + fun setTextColor(@IdRes viewId: Int, @ColorInt textColor: Int): LBindingDialog { + val view = getView(viewId) + view.setTextColor(textColor) + return this + } + + + /** + * Will set the image of an ImageView from a drawable. + * + * @param viewId The view id. + * @param drawable The image drawable. + * @return The BaseViewHolder for chaining. + */ + fun setImageDrawable(@IdRes viewId: Int, drawable: Drawable): LBindingDialog { + val view = getView(viewId) + view.setImageDrawable(drawable) + return this + } + + /** + * Add an action to set the image of an image view. Can be called multiple times. + */ + fun setImageBitmap(@IdRes viewId: Int, bitmap: Bitmap): LBindingDialog { + val view = getView(viewId) + view.setImageBitmap(bitmap) + return this + } + + /** + * Add an action to set the alpha of a view. Can be called multiple times. + * Alpha between 0-1. + */ + fun setAlpha(@IdRes viewId: Int, value: Float): LBindingDialog { + getView(viewId).alpha = value + return this + } + + /** + * Set a view visibility to VISIBLE (true) or GONE (false). + * + * @param viewId The view id. + * @param visible True for VISIBLE, false for GONE. + * @return The BaseViewHolder for chaining. + */ + fun setGone(@IdRes viewId: Int, visible: Boolean): LBindingDialog { + val view = getView(viewId) + view.visibility = if (visible) View.VISIBLE else View.GONE + return this + } + + /** + * Set a view visibility to VISIBLE (true) or INVISIBLE (false). + * + * @param viewId The view id. + * @param visible True for VISIBLE, false for INVISIBLE. + * @return The BaseViewHolder for chaining. + */ + fun setVisible(@IdRes viewId: Int, visible: Boolean): LBindingDialog { + val view = getView(viewId) + view.visibility = if (visible) View.VISIBLE else View.INVISIBLE + return this + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java new file mode 100644 index 0000000..c7d6ed7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/dialoglib/ScreenUtils.java @@ -0,0 +1,77 @@ +package com.remax.visualnovel.widget.dialoglib; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.view.Display; +import android.view.WindowManager; +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.remax.visualnovel.utils.StatusBarUtils; + +/** + * 获取屏幕信息 工具类 + */ +public class ScreenUtils { + /** + * 可用距离: 全屏高度- 状态栏- 导航栏 + * + * @return 可用高度 + */ + public static int getHeightRealPixels() { + return getScreenHeight() - StatusBarUtils.INSTANCE.getStatusBarHeight() - StatusBarUtils.INSTANCE.getNavBarHeight(false); + } + + private static Display getDisplay(Context context) { + WindowManager wm; + if (context instanceof Activity) { + Activity activity = (Activity) context; + wm = activity.getWindowManager(); + } else { + wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + if (wm != null) { + return wm.getDefaultDisplay(); + } + return null; + } + + /** + * Return the width of screen, in pixel. + * + * @return the width of screen, in pixel + */ + public static int getScreenWidth() { + WindowManager wm = (WindowManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.WINDOW_SERVICE); + if (wm == null) return -1; + Point point = new Point(); + wm.getDefaultDisplay().getRealSize(point); + return point.x; + } + + /** + * 获得整个屏幕的高度,包括状态栏和导航栏 + * + * @return the height of screen, in pixel + */ + public static int getScreenHeight() { + WindowManager wm = (WindowManager) CommonApplicationProxy.INSTANCE.getApplication().getSystemService(Context.WINDOW_SERVICE); + if (wm == null) return -1; + Point point = new Point(); + wm.getDefaultDisplay().getRealSize(point); + return point.y; + } + + /** + * 判断当前设备是手机还是平板,代码来自 Google I/O App for Android + * + * @param context + * @return 平板返回 True,手机返回 False + */ + public static boolean isPad(Context context) { + return (context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java new file mode 100644 index 0000000..ff21c25 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/glidetransformation/CropRectTransformation.java @@ -0,0 +1,128 @@ +package com.remax.visualnovel.widget.glidetransformation; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.RectF; +import androidx.annotation.NonNull; +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import java.security.MessageDigest; +import jp.wasabeef.glide.transformations.BitmapTransformation; +import jp.wasabeef.glide.transformations.BuildConfig; + +/** + * Created by HJW on 2023/7/25 + */ +public class CropRectTransformation extends BitmapTransformation { + + private static final int VERSION = 1; + private static final String ID = "com.remax.visualnovel.widget.glidetransformation.CropTransformation." + VERSION; + + public enum CropXType { + TOP, + CENTER, + BOTTOM + } + + public enum CropYType { + LEFT, + CENTER, + RIGHT + } + + private int width; + private int height; + + private final CropXType cropXType; + private final CropYType cropYType; + + public CropRectTransformation(int width, int height) { + this(width, height, CropXType.CENTER, CropYType.CENTER); + } + + public CropRectTransformation(int width, int height, CropXType cropXType, CropYType cropYType) { + this.width = width; + this.height = height; + this.cropXType = cropXType; + this.cropYType = cropYType; + } + + @Override + protected Bitmap transform(@NonNull Context context, @NonNull BitmapPool pool, + @NonNull Bitmap toTransform, int outWidth, int outHeight) { + + width = width == 0 ? toTransform.getWidth() : width; + height = height == 0 ? toTransform.getHeight() : height; + + Bitmap.Config config = + toTransform.getConfig() != null ? toTransform.getConfig() : Bitmap.Config.ARGB_8888; + Bitmap bitmap = pool.get(width, height, config); + + bitmap.setHasAlpha(true); + + float scaleX = (float) width / toTransform.getWidth(); + float scaleY = (float) height / toTransform.getHeight(); + float scale = Math.max(scaleX, scaleY); + + float scaledWidth = scale * toTransform.getWidth(); + float scaledHeight = scale * toTransform.getHeight(); + float left = getLeft(scaledWidth); + float top = getTop(scaledHeight); + RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight); + + bitmap.setDensity(toTransform.getDensity()); + + Canvas canvas = new Canvas(bitmap); + canvas.drawBitmap(toTransform, null, targetRect, null); + + return bitmap; + } + + private float getTop(float scaledHeight) { + switch (cropXType) { + case CENTER: + return (height - scaledHeight) / 2; + case BOTTOM: + return height - scaledHeight; + default: + return 0; + } + } + + private float getLeft(float scaledWidth) { + switch (cropYType) { + case CENTER: + return (width - scaledWidth) / 2; + case RIGHT: + return width - scaledWidth; + default: + return 0; + } + } + + + @NonNull + @Override + public String toString() { + return "CropTransformation(width=" + width + ", height=" + height + ", cropXType=" + cropXType + ", cropYType=" + cropYType + ")"; + } + + @Override + public boolean equals(Object o) { + return o instanceof CropRectTransformation && + ((CropRectTransformation) o).width == width && + ((CropRectTransformation) o).height == height && + ((CropRectTransformation) o).cropXType == cropXType && + ((CropRectTransformation) o).cropYType == cropYType; + } + + @Override + public int hashCode() { + return ID.hashCode() + width * 100000 + height * 1000 + cropXType.ordinal() * 10 + cropYType.ordinal() * 10; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update((ID + width + height + cropXType + cropYType).getBytes(CHARSET)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java new file mode 100644 index 0000000..feeff38 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/ImagePicker.java @@ -0,0 +1,365 @@ +package com.remax.visualnovel.widget.imagepicker; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; + +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.activity.preview.MultiImagePreviewActivity; +import com.remax.visualnovel.widget.imagepicker.activity.singlecrop.SingleCropActivity; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.builder.CropPickerBuilder; +import com.remax.visualnovel.widget.imagepicker.builder.MultiPickerBuilder; +import com.remax.visualnovel.widget.imagepicker.data.MediaItemsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.MediaSetsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; +import com.remax.visualnovel.widget.imagepicker.helper.CameraCompat; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PPermissionUtils; + +import java.util.ArrayList; +import java.util.Set; + +/** + * Description: 图片加载启动类 + *

+ * Author: peixing.yang + * Date: 2019/2/28 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class ImagePicker { + public static String DEFAULT_FILE_NAME = "imagePicker"; + //选择返回的key + public static final String INTENT_KEY_PICKER_RESULT = "pickerResult"; + //选择返回code + public static final int REQ_PICKER_RESULT_CODE = 1433; + //拍照返回码、拍照权限码 + public static final int REQ_CAMERA = 1431; + //存储权限码 + public static final int REQ_STORAGE = 1432; + + /** + * 是否选中原图 + */ + public static boolean isOriginalImage = false; + + private static int themeColor = Color.RED; + + private static boolean previewWithHighQuality = false; + + /** + * @param previewWithHighQuality 预览是否极致高清,true会导致放大后滑动卡顿,false在加载超过3K图片时,放大后部分像素丢失 + */ + public static void setPreviewWithHighQuality(boolean previewWithHighQuality) { + ImagePicker.previewWithHighQuality = previewWithHighQuality; + } + + public static boolean isPreviewWithHighQuality() { + return previewWithHighQuality; + } + + /** + * 小红书样式剪裁activity形式 + * + * @param presenter 数据交互类 + */ + public static CropPickerBuilder withCrop(IPickerPresenter presenter) { + return new CropPickerBuilder(presenter); + } + + /** + * 微信样式多选 + * + * @param presenter 选择器UI提供者 + * @return 微信样式多选 + */ + public static MultiPickerBuilder withMulti(IPickerPresenter presenter) { + return new MultiPickerBuilder(presenter); + } + + /** + * 兼容安卓10拍照.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity 调用拍照的页面 + * @param imageName 图片名称 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 拍照回调 + */ + public static void takePhoto(Activity activity, + String imageName, + boolean isCopyInDCIM, + OnImagePickCompleteListener listener) { + if (imageName == null || imageName.length() == 0) { + imageName = "Img_" + System.currentTimeMillis(); + } + CameraCompat.takePhoto(activity, imageName, isCopyInDCIM, listener); + } + + /** + * 兼容安卓10拍摄视频.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity activity + * @param videoName 视频名称 + * @param maxDuration 视频最大时长 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 视频回调 + */ + public static void takeVideo(Activity activity, + String videoName, + long maxDuration, + boolean isCopyInDCIM, + OnImagePickCompleteListener listener) { + if (videoName == null || videoName.length() == 0) { + videoName = "Video_" + System.currentTimeMillis(); + } + CameraCompat.takeVideo(activity, videoName, maxDuration, isCopyInDCIM, listener); + } + + + /** + * 直接调用拍照并剪裁 + * + * @param activity 调用activity + * @param presenter 选择器样式类,主要负责返回UIConfig + * @param cropConfig 剪裁配置 + * @param listener 剪裁回调 + */ + public static void takePhotoAndCrop(final Activity activity, + final IPickerPresenter presenter, + final CropConfig cropConfig, + @NonNull final OnImagePickCompleteListener listener) { + if (presenter == null) { + PickerErrorExecutor.executeError(activity, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + if (cropConfig == null) { + PickerErrorExecutor.executeError(activity, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return; + } + takePhoto(activity, null, false, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + if (items != null && items.size() > 0) { + SingleCropActivity.intentCrop(activity, presenter, cropConfig, items.get(0), listener); + } + } + }); + } + + /** + * 直接调用拍照并剪裁 + * + * @param activity 调用activity + * @param presenter 选择器样式类,主要负责返回UIConfig + * @param cropConfig 剪裁配置 + * @param cropImagePath 需要剪裁的图片路径,可以是uri路径 + * @param listener 剪裁回调 + */ + public static void crop(final Activity activity, final IPickerPresenter presenter, + final CropConfig cropConfig, String cropImagePath, + final OnImagePickCompleteListener listener) { + if (presenter == null || cropConfig == null || listener == null) { + PickerErrorExecutor.executeError(activity, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + SingleCropActivity.intentCrop(activity, presenter, cropConfig, cropImagePath, listener); + } + + /** + * 直接调用拍照并剪裁 + * + * @param activity 调用activity + * @param presenter 选择器样式类,主要负责返回UIConfig + * @param cropConfig 剪裁配置 + * @param imageItem 需要剪裁的图片信息 + * @param listener 剪裁回调 + */ + public static void crop(final Activity activity, final IPickerPresenter presenter, + final CropConfig cropConfig, ImageItem imageItem, + final OnImagePickCompleteListener listener) { + if (presenter == null || cropConfig == null || listener == null) { + PickerErrorExecutor.executeError(activity, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + SingleCropActivity.intentCrop(activity, presenter, cropConfig, imageItem, listener); + } + + /** + * 图片预览 + * + * @param context 上下文 + * @param imageList 预览的图片数组 + * @param pos 默认位置 + * @param listener 编辑回调 + * @param String or ImageItem + */ + public static void preview(Activity context, final IPickerPresenter presenter, ArrayList imageList, + int pos, final OnImagePickCompleteListener listener) { + if (imageList == null || imageList.size() == 0) { + return; + } + MultiSelectConfig selectConfig = new MultiSelectConfig(); + selectConfig.setMaxCount(imageList.size()); + MultiImagePreviewActivity.intent(context, null, transitArray(context, imageList), + selectConfig, presenter, pos, (imageItems, isCancel) -> { + if (listener != null) { + if (isCancel && listener instanceof OnImagePickCompleteListener2) { + ((OnImagePickCompleteListener2) listener).onPickFailed(PickerError.CANCEL); + } else { + listener.onImagePickComplete(imageItems); + } + } + }); + } + + /** + * @param imageList 需要转化的list + * @param ImageItem or String + * @return 转化后可识别的item列表 + */ + public static ArrayList transitArray(Activity activity, ArrayList imageList) { + ArrayList items = new ArrayList<>(); + for (T t : imageList) { + if (t instanceof String) { + ImageItem imageItem = ImageItem.withPath(activity, (String) t); + items.add(imageItem); + } else if (t instanceof ImageItem) { + items.add((ImageItem) t); + } else if (t instanceof Uri) { + Uri uri = (Uri) t; + ImageItem imageItem = new ImageItem(); + imageItem.path = uri.toString(); + imageItem.mimeType = PBitmapUtils.getMimeTypeFromUri(activity, uri); + imageItem.setVideo(MimeType.isVideo(imageItem.mimeType)); + imageItem.setUriPath(uri.toString()); + items.add(imageItem); + } else { + throw new RuntimeException("ImageList item must be instanceof String or Uri or ImageItem"); + } + } + return items; + } + + /** + * 提供媒体相册列表 + * + * @param activity 调用activity + * @param mimeTypeSet 指定相册文件类型 + * @param provider 相回调 + */ + public static void provideMediaSets(FragmentActivity activity, + Set mimeTypeSet, + MediaSetsDataSource.MediaSetProvider provider) { + if (PPermissionUtils.hasStoragePermissions(activity)) { + MediaSetsDataSource.create(activity).setMimeTypeSet(mimeTypeSet).loadMediaSets(provider); + } + } + + /** + * 根据相册提供媒体数据 + * + * @param activity 调用activity + * @param set 相册文件 + * @param mimeTypeSet 加载类型 + * @param provider 媒体文件回调 + */ + public static void provideMediaItemsFromSet(FragmentActivity activity, + ImageSet set, + Set mimeTypeSet, + MediaItemsDataSource.MediaItemProvider provider) { + if (PPermissionUtils.hasStoragePermissions(activity)) { + MediaItemsDataSource.create(activity, set).setMimeTypeSet(mimeTypeSet).loadMediaItems(provider); + } + } + + /** + * 根据相册提供媒体数据,预加载指定数目 + * + * @param activity 调用activity + * @param set 相册文件 + * @param mimeTypeSet 加载类型 + * @param preloadSize 预加载个数 + * @param preloadProvider 预加载回调 + * @param provider 所有文件回调 + */ + public static void provideMediaItemsFromSetWithPreload(FragmentActivity activity, + ImageSet set, + Set mimeTypeSet, + int preloadSize, + MediaItemsDataSource.MediaItemPreloadProvider preloadProvider, + MediaItemsDataSource.MediaItemProvider provider) { + if (PPermissionUtils.hasStoragePermissions(activity)) { + MediaItemsDataSource dataSource = MediaItemsDataSource.create(activity, set) + .setMimeTypeSet(mimeTypeSet) + .preloadSize(preloadSize); + dataSource.setPreloadProvider(preloadProvider); + dataSource.loadMediaItems(provider); + } + } + + + /** + * 提供所有媒体数据 + * + * @param activity 调用activity + * @param mimeTypeSet 加载文件类型 + * @param provider 文件列表回调 + */ + public static void provideAllMediaItems(FragmentActivity activity, + Set mimeTypeSet, + MediaItemsDataSource.MediaItemProvider provider) { + ImageSet set = new ImageSet(); + set.id = ImageSet.ID_ALL_MEDIA; + provideMediaItemsFromSet(activity, set, mimeTypeSet, provider); + } + + /** + * 关闭选择器并回调数据 + * + * @param list 回调数组 + */ + public static void closePickerWithCallback(ArrayList list) { + Activity activity = PickerActivityManager.getLastActivity(); + if (activity == null || list == null || list.size() == 0) { + return; + } + Intent intent = new Intent(); + intent.putExtra(ImagePicker.INTENT_KEY_PICKER_RESULT, list); + activity.setResult(ImagePicker.REQ_PICKER_RESULT_CODE, intent); + activity.finish(); + PickerActivityManager.clear(); + } + + /** + * 关闭选择器并回调数据 + * + * @param imageItem 回调数据 + */ + public static void closePickerWithCallback(ImageItem imageItem) { + ArrayList imageItems = new ArrayList<>(); + imageItems.add(imageItem); + closePickerWithCallback(imageItems); + } + + public static int getThemeColor() { + return themeColor; + } + + public static void setThemeColor(int themeColor) { + ImagePicker.themeColor = themeColor; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java new file mode 100644 index 0000000..db96015 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PBaseLoaderFragment.java @@ -0,0 +1,521 @@ +package com.remax.visualnovel.widget.imagepicker.activity; + +import static com.remax.visualnovel.widget.imagepicker.ImagePicker.REQ_CAMERA; +import static com.remax.visualnovel.widget.imagepicker.ImagePicker.REQ_STORAGE; + +import android.Manifest; +import android.app.Activity; +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.ICameraExecutor; +import com.remax.visualnovel.widget.imagepicker.data.MediaItemsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PPermissionUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PStatusBarUtil; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiProvider; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView; +import com.hjq.permissions.permission.PermissionNames; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + + +/** + * Description: 选择器加载基类,主要处理媒体文件的加载和权限管理 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public abstract class PBaseLoaderFragment extends Fragment implements ICameraExecutor { + //选中图片列表 + protected ArrayList selectList = new ArrayList<>(); + + /** + * @return 获取选择器配置项,主要用于加载文件类型的指定 + */ + @NonNull + protected abstract BaseSelectConfig getSelectConfig(); + + /** + * @return 获取presenter + */ + @NonNull + protected abstract IPickerPresenter getPresenter(); + + /** + * @return 获取presenter + */ + @NonNull + protected abstract PickerUiConfig getUiConfig(); + + /** + * 执行回调 + */ + protected abstract void notifyPickerComplete(); + + /** + * 切换文件夹 + */ + protected abstract void toggleFolderList(); + + /** + * 跳转预览页面 + * + * @param isClickItem 是否是item点击 + * @param index 当前图片位于预览列表数据源的索引 + */ + protected abstract void intentPreview(boolean isClickItem, int index); + + /** + * @param imageSetList 媒体文件夹加载完成回调 + */ + protected abstract void loadMediaSetsComplete(@Nullable List imageSetList); + + /** + * @param set 媒体文件夹内文件加载完成回调 + */ + protected abstract void loadMediaItemsComplete(@Nullable ImageSet set); + + /** + * @param allVideoSet 刷新所有视频的文件夹 + */ + protected abstract void refreshAllVideoSet(@Nullable ImageSet allVideoSet); + + + /** + * @return 返回需要判断当前文件夹列表是否打开 + */ + public boolean onBackPressed() { + return false; + } + + + /** + * @param imageItem 回调一张图片 + */ + protected void notifyOnSingleImagePickComplete(ImageItem imageItem) { + selectList.clear(); + selectList.add(imageItem); + notifyPickerComplete(); + } + + + /** + * 是否超过最大限制数 + * + * @return true:超过 + */ + private boolean isOverMaxCount() { + if (selectList.size() >= getSelectConfig().getMaxCount()) { + getPresenter().overMaxCountTip(getContext(), getSelectConfig().getMaxCount()); + return true; + } + return false; + } + + /** + * 检测当前拍照item是拍照还是录像 + */ + protected void checkTakePhotoOrVideo() { + if (getSelectConfig().isShowVideo() && !getSelectConfig().isShowImage()) { + takeVideo(); + } else { + takePhoto(); + } + } + + /** + * 拍照 + */ + @Override + public void takePhoto() { + if (getActivity() == null || isOverMaxCount()) { + return; + } + if (ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, REQ_CAMERA); + } else { + ImagePicker.takePhoto(getActivity(), null, + true, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + if (items != null && items.size() > 0 && items.get(0) != null) { + onTakePhotoResult(items.get(0)); + } + } + }); + } + } + + @Override + public void takeVideo() { + if (getActivity() == null || isOverMaxCount()) { + return; + } + if (ContextCompat.checkSelfPermission(getActivity(), Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.CAMERA}, REQ_CAMERA); + } else { + ImagePicker.takeVideo(getActivity(), null, getSelectConfig().getMaxVideoDuration(), + true, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + if (items != null && items.size() > 0 && items.get(0) != null) { + onTakePhotoResult(items.get(0)); + } + } + }); + } + } + + /** + * 加载媒体文件夹 + */ + protected void loadMediaSets() { + if (getActivity() == null) { + return; + } + String permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? PermissionNames.READ_MEDIA_IMAGES : PermissionNames.WRITE_EXTERNAL_STORAGE; + if (ContextCompat.checkSelfPermission(getActivity(), permission) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{permission}, REQ_STORAGE); + } else { + //从媒体库拿到文件夹列表 + ImagePicker.provideMediaSets(getActivity(), getSelectConfig().getMimeTypes(), this::loadMediaSetsComplete); + } + } + + /** + * 根据指定的媒体 文件夹加载文件 + * + * @param set 文件夹 + */ + protected void loadMediaItemsFromSet(final @NonNull ImageSet set) { + if (set.imageItems == null || set.imageItems.size() == 0) { + DialogInterface dialogInterface = null; + if (!set.isAllMedia() && set.count > 1000) { + dialogInterface = getPresenter(). + showProgressDialog(getWeakActivity(), ProgressSceneEnum.loadMediaItem); + } + final BaseSelectConfig selectConfig = getSelectConfig(); + final DialogInterface finalDialogInterface = dialogInterface; + ImagePicker.provideMediaItemsFromSetWithPreload(getActivity(), set, selectConfig.getMimeTypes(), + 40, new MediaItemsDataSource.MediaItemPreloadProvider() { + @Override + public void providerMediaItems(ArrayList imageItems) { + if (finalDialogInterface != null) { + finalDialogInterface.dismiss(); + } + set.imageItems = imageItems; + loadMediaItemsComplete(set); + } + }, new MediaItemsDataSource.MediaItemProvider() { + @Override + public void providerMediaItems(ArrayList imageItems, ImageSet allVideoSet) { + if (finalDialogInterface != null) { + finalDialogInterface.dismiss(); + } + set.imageItems = imageItems; + loadMediaItemsComplete(set); + if (selectConfig.isShowImage() && selectConfig.isShowVideo()) { + refreshAllVideoSet(allVideoSet); + } + } + }); + } else { + loadMediaItemsComplete(set); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == REQ_CAMERA) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + //申请成功,可以拍照 + takePhoto(); + } else { + PPermissionUtils.create(getContext()).showSetPermissionDialog( + getString(R.string.picker_str_camera_permission)); + } + } else if (requestCode == REQ_STORAGE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + //申请成功,可以拍照 + loadMediaSets(); + } else { + PPermissionUtils.create(getContext()). + showSetPermissionDialog(getString(R.string.picker_str_storage_permission)); + } + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + + protected PickerControllerView titleBar; + protected PickerControllerView bottomBar; + + /** + * 加载自定义控制器布局 + * + * @param container 布局容器 + * @param isTitle 是否是顶部栏 + * @param uiConfig ui配置 + * @return 当前需要记载的控制器 + */ + protected PickerControllerView inflateControllerView(ViewGroup container, boolean isTitle, PickerUiConfig uiConfig) { + final BaseSelectConfig selectConfig = getSelectConfig(); + PickerUiProvider uiProvider = uiConfig.getPickerUiProvider(); + PickerControllerView view = isTitle ? uiProvider.getTitleBar(getWeakActivity()) : + uiProvider.getBottomBar(getWeakActivity()); + if (view != null && view.isAddInParent()) { + container.addView(view, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + if (selectConfig.isShowVideo() && selectConfig.isShowImage()) { + view.setTitle(getString(R.string.picker_str_title_all)); + } else if (selectConfig.isShowVideo()) { + view.setTitle(getString(R.string.picker_str_title_video)); + } else { + view.setTitle(getString(R.string.picker_str_title_image)); + } + final PickerControllerView finalView = view; + + View.OnClickListener clickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + if (v == finalView.getCanClickToCompleteView()) { + notifyPickerComplete(); + } else if (v == finalView.getCanClickToToggleFolderListView()) { + toggleFolderList(); + } else { + intentPreview(false, 0); + } + } + }; + + if (view.getCanClickToCompleteView() != null) { + view.getCanClickToCompleteView().setOnClickListener(clickListener); + } + + if (view.getCanClickToToggleFolderListView() != null) { + view.getCanClickToToggleFolderListView().setOnClickListener(clickListener); + } + + if (view.getCanClickToIntentPreviewView() != null) { + view.getCanClickToIntentPreviewView().setOnClickListener(clickListener); + } + } + + return view; + } + + /** + * 控制器view执行切换文件夹操作 + * + * @param isOpen 是否是打开文件夹 + */ + protected void controllerViewOnTransitImageSet(boolean isOpen) { + if (titleBar != null) { + titleBar.onTransitImageSet(isOpen); + } + if (bottomBar != null) { + bottomBar.onTransitImageSet(isOpen); + } + } + + /** + * 控制器view执行文件夹选择完成 + * + * @param set 当前选择文件夹 + */ + protected void controllerViewOnImageSetSelected(ImageSet set) { + if (titleBar != null) { + titleBar.onImageSetSelected(set); + } + if (bottomBar != null) { + bottomBar.onImageSetSelected(set); + } + } + + /** + * 刷新完成按钮 + */ + protected void refreshCompleteState() { + if (titleBar != null) { + titleBar.refreshCompleteViewState(selectList, getSelectConfig()); + } + + if (bottomBar != null) { + bottomBar.refreshCompleteViewState(selectList, getSelectConfig()); + } + } + + /** + * 设置文件夹列表的高度 + * + * @param mFolderListRecyclerView 文件夹列表 + * @param mImageSetMask 文件夹列表的灰色透明蒙层 + * @param isCrop 是否是小红书样式 + */ + protected void setFolderListHeight(RecyclerView mFolderListRecyclerView, View mImageSetMask, boolean isCrop) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) mFolderListRecyclerView.getLayoutParams(); + RelativeLayout.LayoutParams maskParams = (RelativeLayout.LayoutParams) mImageSetMask.getLayoutParams(); + PickerUiConfig uiConfig = getUiConfig(); + int height = uiConfig.getFolderListOpenMaxMargin(); + if (uiConfig.getFolderListOpenDirection() == PickerUiConfig.DIRECTION_BOTTOM) { + params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, RelativeLayout.TRUE); + if (isCrop) { + params.bottomMargin = bottomBar != null ? bottomBar.getViewHeight() : 0; + params.topMargin = (titleBar != null ? titleBar.getViewHeight() : 0) + height; + maskParams.topMargin = (titleBar != null ? titleBar.getViewHeight() : 0); + maskParams.bottomMargin = bottomBar != null ? bottomBar.getViewHeight() : 0; + } else { + params.bottomMargin = 0; + params.topMargin = height; + } + } else { + params.addRule(RelativeLayout.ALIGN_PARENT_TOP, RelativeLayout.TRUE); + if (isCrop) { + params.bottomMargin = height + (bottomBar != null ? bottomBar.getViewHeight() : 0); + params.topMargin = titleBar != null ? titleBar.getViewHeight() : 0; + maskParams.topMargin = (titleBar != null ? titleBar.getViewHeight() : 0); + maskParams.bottomMargin = bottomBar != null ? bottomBar.getViewHeight() : 0; + } else { + params.bottomMargin = height; + params.topMargin = 0; + } + } + mFolderListRecyclerView.setLayoutParams(params); + mImageSetMask.setLayoutParams(maskParams); + } + + /** + * 是否拦截不可点击的item + * + * @param disableItemCode 不可点击的item的code码 + * @param isCheckOverMaxCount 是否校验超过最大数量时候的item + * @return 是否拦截掉 + */ + protected boolean interceptClickDisableItem(int disableItemCode, boolean isCheckOverMaxCount) { + if (disableItemCode != PickerItemDisableCode.NORMAL) { + if (!isCheckOverMaxCount && disableItemCode == PickerItemDisableCode.DISABLE_OVER_MAX_COUNT) { + return false; + } + String message = PickerItemDisableCode.getMessageFormCode(getActivity(), disableItemCode, getPresenter(), getSelectConfig()); + if (message.length() > 0) { + getPresenter().tip(getWeakActivity(), message); + } + return true; + } + return false; + } + + + /** + * 添加一个图片到文件夹列表里。一般在拍照完成的回调里会执行该方法,用于手动添加 + * 一个item到指定的文件夹列表里 + * + * @param imageSets 当前的文件夹列表 + * @param imageItems 当前文件夹列表里面的item数组 + * @param imageItem 当前要插入的文件 + */ + protected void addItemInImageSets(@NonNull List imageSets, + @NonNull List imageItems, + @NonNull ImageItem imageItem) { + imageItems.add(0, imageItem); + if (imageSets.size() == 0) { + String firstImageSetName; + if (imageItem.isVideo()) { + firstImageSetName = getActivity().getString(R.string.picker_str_folder_item_video); + } else { + firstImageSetName = getActivity().getString(R.string.picker_str_folder_item_image); + } + ImageSet imageSet = ImageSet.allImageSet(firstImageSetName); + imageSet.cover = imageItem; + imageSet.coverPath = imageItem.path; + imageSet.imageItems = (ArrayList) imageItems; + imageSet.count = imageSet.imageItems.size(); + imageSets.add(imageSet); + } else { + imageSets.get(0).imageItems = (ArrayList) imageItems; + imageSets.get(0).cover = imageItem; + imageSets.get(0).coverPath = imageItem.path; + imageSets.get(0).count = imageItems.size(); + } + } + + private WeakReference weakReference; + + /** + * @return 获取弱引用的activity对象 + */ + protected Activity getWeakActivity() { + if (getActivity() != null) { + if (weakReference == null) { + weakReference = new WeakReference(getActivity()); + } + return weakReference.get(); + } + return null; + } + + protected void tip(String msg) { + getPresenter().tip(getWeakActivity(), msg); + } + + final public int dp(float dp) { + if (getActivity() == null || getContext() == null) { + return 0; + } + float density = getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + private long lastTime = 0L; + + protected boolean onDoubleClick() { + boolean flag = false; + long time = System.currentTimeMillis() - lastTime; + + if (time > 300) { + flag = true; + } + lastTime = System.currentTimeMillis(); + return !flag; + } + + /** + * 设置是否显示状态栏 + */ + protected void setStatusBar() { + if (getActivity() != null) { + //刘海屏幕需要适配状态栏颜色 + if (getUiConfig().isShowStatusBar() || PStatusBarUtil.hasNotchInScreen(getActivity())) { + PStatusBarUtil.setStatusBar(getActivity(), getUiConfig().getStatusBarColor(), + false, PStatusBarUtil.isDarkColor(getUiConfig().getStatusBarColor())); + } else { + PStatusBarUtil.fullScreen(getActivity()); + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java new file mode 100644 index 0000000..14dafa2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/PickerActivityManager.java @@ -0,0 +1,66 @@ +package com.remax.visualnovel.widget.imagepicker.activity; + +import android.app.Activity; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + +/** + * Time: 2019/11/6 17:09 + * Author:ypx + * Description: 自定义activity栈 + */ +public class PickerActivityManager { + + private static List> activities = new ArrayList<>(); + + public static void addActivity(Activity activity) { + WeakReference activityWeakReference = new WeakReference<>(activity); + if (activities == null) { + activities = new ArrayList<>(); + } + activities.add(activityWeakReference); + } + + public static void removeActivity(Activity activity) { + if (activities == null || activities.size() == 0) { + return; + } + WeakReference activityWeakReference = null; + for (WeakReference activityWeakReference1 : activities) { + if (activityWeakReference1 != null + && activityWeakReference1.get() != null + && activityWeakReference1.get() == activity) { + activityWeakReference = activityWeakReference1; + break; + } + } + if (activityWeakReference != null) { + activities.remove(activityWeakReference); + } + } + + public static Activity getLastActivity() { + if (activities != null && activities.size() > 0) { + WeakReference activityWeakReference = activities.get(activities.size() - 1); + if (activityWeakReference != null) { + return activityWeakReference.get(); + } + } + return null; + } + + public static void clear() { + if (activities != null && activities.size() > 0) { + for (int i = 0; i < activities.size(); i++) { + WeakReference activityWeakReference = activities.get(i); + if (activityWeakReference.get() != null && !activityWeakReference.get().isDestroyed()) { + activityWeakReference.get().finish(); + } + } + activities.clear(); + activities = null; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java new file mode 100644 index 0000000..6dbfa4a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropActivity.java @@ -0,0 +1,122 @@ +package com.remax.visualnovel.widget.imagepicker.activity.crop; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; +import com.remax.visualnovel.widget.imagepicker.data.PickerActivityCallBack; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; + + +import java.util.ArrayList; + +/** + * Description: 图片选择和剪裁页面 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImageCropActivity extends AppCompatActivity { + public static final String INTENT_KEY_DATA_PRESENTER = "ICropPickerBindPresenter"; + public static final String INTENT_KEY_SELECT_CONFIG = "selectConfig"; + private MultiImageCropFragment mFragment; + private IPickerPresenter presenter; + private CropSelectConfig selectConfig; + + /** + * 跳转小红书剪裁页面 + * + * @param activity 跳转activity + * @param presenter ICropPickerBindPresenter + * @param selectConfig 选择器配置 + * @param listener 选择回调 + */ + public static void intent(@NonNull Activity activity, @NonNull IPickerPresenter presenter, + @NonNull CropSelectConfig selectConfig, final @NonNull OnImagePickCompleteListener listener) { + if (!PViewSizeUtils.onDoubleClick()) { + Intent intent = new Intent(activity, MultiImageCropActivity.class); + intent.putExtra(MultiImageCropActivity.INTENT_KEY_DATA_PRESENTER, presenter); + intent.putExtra(MultiImageCropActivity.INTENT_KEY_SELECT_CONFIG, selectConfig); + PLauncher.init(activity).startActivityForResult(intent, PickerActivityCallBack.create(listener)); + } + } + + /** + * 校验传递数据是否合法 + */ + private boolean isIntentDataFailed() { + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_DATA_PRESENTER); + selectConfig = (CropSelectConfig) getIntent().getSerializableExtra(INTENT_KEY_SELECT_CONFIG); + if (presenter == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return true; + } + if (selectConfig == null) { + PickerErrorExecutor.executeError(this, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return true; + } + return false; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (isIntentDataFailed()) { + return; + } + PickerActivityManager.addActivity(this); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.picker_activity_fragment_wrapper); + setFragment(); + } + + /** + * 填充fragment + */ + private void setFragment() { + mFragment = ImagePicker.withCrop(presenter) + .withSelectConfig(selectConfig) + .pickWithFragment(new OnImagePickCompleteListener2() { + @Override + public void onPickFailed(PickerError error) { + PickerErrorExecutor.executeError(MultiImageCropActivity.this, error.getCode()); + PickerActivityManager.clear(); + } + + @Override + public void onImagePickComplete(ArrayList items) { + ImagePicker.closePickerWithCallback(items); + } + }); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, mFragment) + .commit(); + } + + @Override + public void onBackPressed() { + if (null != mFragment && mFragment.onBackPressed()) { + return; + } + super.onBackPressed(); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java new file mode 100644 index 0000000..44588bb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/crop/MultiImageCropFragment.java @@ -0,0 +1,827 @@ +package com.remax.visualnovel.widget.imagepicker.activity.crop; + +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + + + + +import java.util.ArrayList; +import java.util.List; + +import static com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropActivity.INTENT_KEY_DATA_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropActivity.INTENT_KEY_SELECT_CONFIG; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PBaseLoaderFragment; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerFolderAdapter; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerItemAdapter; +import com.remax.visualnovel.widget.imagepicker.bean.ImageCropMode; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.CropViewContainerHelper; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.RecyclerViewTouchHelper; +import com.remax.visualnovel.widget.imagepicker.helper.VideoViewContainerHelper; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PCornerUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.widget.TouchRecyclerView; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + + +/** + * Description: 图片选择和剪裁fragment + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImageCropFragment extends PBaseLoaderFragment implements View.OnClickListener, + PickerFolderAdapter.FolderSelectResult, + PickerItemAdapter.OnActionResult { + private TouchRecyclerView mGridImageRecyclerView; + private RecyclerView mFolderListRecyclerView; + private TextView mTvFullOrGap; + private CropImageView mCropView; + private ImageButton stateBtn; + private FrameLayout mCropContainer; + private RelativeLayout mCropLayout; + private LinearLayout mInvisibleContainer; + private View maskView, mImageSetMasker; + private PickerItemAdapter imageGridAdapter; + private PickerFolderAdapter folderAdapter; + private List imageSets = new ArrayList<>(); + private List imageItems = new ArrayList<>(); + private int mCropSize; + private int pressImageIndex = 0; + //滑动辅助类 + private RecyclerViewTouchHelper touchHelper; + //图片加载提供者 + private IPickerPresenter presenter; + //选择配置项 + private CropSelectConfig selectConfig; + // 默认剪裁模式:充满 + private int cropMode = ImageCropMode.CropViewScale_FULL; + private ImageItem currentImageItem; + private View mContentView; + // fragment 形式调用的图片选中回调 + private OnImagePickCompleteListener imageListener; + //剪裁view或videoView填充辅助类 + private CropViewContainerHelper cropViewContainerHelper; + private VideoViewContainerHelper videoViewContainerHelper; + //UI配置类 + private PickerUiConfig uiConfig; + + private FrameLayout titleBarContainer; + private FrameLayout bottomBarContainer; + private FrameLayout titleBarContainer2; + + private ImageItem lastPressItem; + + /** + * @param imageListener 选择回调监听 + */ + public void setOnImagePickCompleteListener(@NonNull OnImagePickCompleteListener imageListener) { + this.imageListener = imageListener; + } + + @NonNull + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container, @NonNull Bundle savedInstanceState) { + mContentView = inflater.inflate(R.layout.picker_activity_multi_crop, container, false); + return mContentView; + } + + @Override + public void onViewCreated(@NonNull View view, @NonNull Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (isIntentDataValid()) { + ImagePicker.isOriginalImage = false; + uiConfig = presenter.getUiConfig(getWeakActivity()); + setStatusBar(); + initView(); + initUI(); + initGridImagesAndImageSets(); + loadMediaSets(); + } + } + + /** + * 校验传递数据 + */ + private boolean isIntentDataValid() { + Bundle arguments = getArguments(); + if (null != arguments) { + presenter = (IPickerPresenter) arguments.getSerializable(INTENT_KEY_DATA_PRESENTER); + selectConfig = (CropSelectConfig) arguments.getSerializable(INTENT_KEY_SELECT_CONFIG); + } + + if (presenter == null) { + PickerErrorExecutor.executeError(imageListener, PickerError.PRESENTER_NOT_FOUND.getCode()); + return false; + } + + if (selectConfig == null) { + PickerErrorExecutor.executeError(imageListener, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return false; + } + return true; + } + + /** + * 初始化界面 + */ + private void initView() { + titleBarContainer = mContentView.findViewById(R.id.titleBarContainer); + titleBarContainer2 = mContentView.findViewById(R.id.titleBarContainer2); + bottomBarContainer = mContentView.findViewById(R.id.bottomBarContainer); + mTvFullOrGap = mContentView.findViewById(R.id.mTvFullOrGap); + mImageSetMasker = mContentView.findViewById(R.id.mImageSetMasker); + maskView = mContentView.findViewById(R.id.v_mask); + mCropContainer = mContentView.findViewById(R.id.mCroupContainer); + mInvisibleContainer = mContentView.findViewById(R.id.mInvisibleContainer); + RelativeLayout topView = mContentView.findViewById(R.id.topView); + mCropLayout = mContentView.findViewById(R.id.mCropLayout); + stateBtn = mContentView.findViewById(R.id.stateBtn); + mGridImageRecyclerView = mContentView.findViewById(R.id.mRecyclerView); + mFolderListRecyclerView = mContentView.findViewById(R.id.mImageSetRecyclerView); + mTvFullOrGap.setBackground(PCornerUtils.cornerDrawable(Color.parseColor("#80000000"), dp(15))); + //初始化监听 + stateBtn.setOnClickListener(this); + maskView.setOnClickListener(this); + mImageSetMasker.setOnClickListener(this); + mTvFullOrGap.setOnClickListener(this); + //防止点击穿透 + mCropLayout.setClickable(true); + //蒙层隐藏 + maskView.setAlpha(0f); + maskView.setVisibility(View.GONE); + //初始化相关尺寸信息 + mCropSize = PViewSizeUtils.getScreenWidth(getActivity()); + PViewSizeUtils.setViewSize(mCropLayout, mCropSize, 1.0f); + //recyclerView和topView的联动效果辅助类 + touchHelper = RecyclerViewTouchHelper.create(mGridImageRecyclerView) + .setTopView(topView) + .setMaskView(maskView) + .setCanScrollHeight(mCropSize) + .build(); + //剪裁控件辅助类 + cropViewContainerHelper = new CropViewContainerHelper(mCropContainer); + //视频控件辅助类 + videoViewContainerHelper = new VideoViewContainerHelper(); + //指定默认剪裁模式 + if (selectConfig.hasFirstImageItem()) { + cropMode = selectConfig.getFirstImageItem().getCropMode(); + } + } + + /** + * 初始化自定义样式 + */ + private void initUI() { + //拿到自定义标题栏和底部栏 + titleBar = inflateControllerView(titleBarContainer, true, uiConfig); + bottomBar = inflateControllerView(bottomBarContainer, false, uiConfig); + //如果包含标题栏 + if (titleBar != null) { + PViewSizeUtils.setMarginTop(mCropLayout, titleBar.getViewHeight()); + touchHelper.setStickHeight(titleBar.getViewHeight()); + } + //如果包含底部栏 + if (bottomBar != null) { + PViewSizeUtils.setMarginTopAndBottom(mGridImageRecyclerView, 0, bottomBar.getViewHeight()); + } + //设置基础样式 + mCropContainer.setBackgroundColor(uiConfig.getCropViewBackgroundColor()); + mGridImageRecyclerView.setBackgroundColor(uiConfig.getPickerBackgroundColor()); + stateBtn.setImageDrawable(getResources().getDrawable(uiConfig.getFullIconID())); + mTvFullOrGap.setCompoundDrawablesWithIntrinsicBounds(getResources(). + getDrawable(uiConfig.getFillIconID()), null, null, null); + //设置相册列表高度 + setFolderListHeight(mFolderListRecyclerView, mImageSetMasker, true); + } + + /** + * 初始化图片列表 + */ + private void initGridImagesAndImageSets() { + GridLayoutManager gridLayoutManager = new GridLayoutManager(getContext(), selectConfig.getColumnCount()); + mGridImageRecyclerView.setLayoutManager(gridLayoutManager); + imageGridAdapter = new PickerItemAdapter(selectList, imageItems, selectConfig, presenter, uiConfig); + imageGridAdapter.setHasStableIds(true); + mGridImageRecyclerView.setAdapter(imageGridAdapter); + //初始化文件夹列表 + mFolderListRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + folderAdapter = new PickerFolderAdapter(presenter, uiConfig); + mFolderListRecyclerView.setAdapter(folderAdapter); + folderAdapter.refreshData(imageSets); + mFolderListRecyclerView.setVisibility(View.GONE); + folderAdapter.setFolderSelectResult(this); + imageGridAdapter.setOnActionResult(this); + } + + @Override + public void onClick(@NonNull View view) { + if (imageItems == null || imageItems.size() == 0) { + return; + } + if (onDoubleClick()) { + tip(getActivity().getString(R.string.picker_str_tip_action_frequently)); + return; + } + if (view == stateBtn) { + fullOrFit(); + } else if (view == maskView) { + touchHelper.transitTopWithAnim(true, pressImageIndex, true); + } else if (view == mTvFullOrGap) { + fullOrGap(); + } else if (mImageSetMasker == view) { + toggleFolderList(); + } + } + + + /** + * 点击操作 + * + * @param imageItem 当前item + * @param position 当前item的position + */ + @Override + public void onClickItem(@NonNull ImageItem imageItem, int position, int disableItemCode) { + //拍照 + if (position <= 0 && selectConfig.isShowCamera()) { + //拦截拍照点击 + if (presenter.interceptCameraClick(getWeakActivity(), this)) { + return; + } + checkTakePhotoOrVideo(); + return; + } + + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, false)) { + return; + } + + //得到当前选中的item索引 + pressImageIndex = position; + //防止数组越界 + if (imageItems == null || imageItems.size() == 0 || + imageItems.size() <= pressImageIndex) { + return; + } + + //是否拦截当前item的点击事件 + if (isInterceptItemClick(imageItem, false)) { + return; + } + + //选中当前item + onPressImage(imageItem, true); + } + + + private boolean isInterceptItemClick(ImageItem imageItem, boolean isClickCheckbox) { + return !imageGridAdapter.isPreformClick() && presenter.interceptItemClick(getWeakActivity(), imageItem, selectList, + (ArrayList) imageItems, selectConfig, imageGridAdapter, isClickCheckbox, + null); + } + + /** + * 点击图片 + * + * @param imageItem 图片 + */ + private void onPressImage(ImageItem imageItem, boolean isShowTransit) { + currentImageItem = imageItem; + if (lastPressItem != null) { + //如果当前选中的item和上一次选中的一致,则不处理 + if (lastPressItem.equals(currentImageItem)) { + return; + } + //取消上次选中 + lastPressItem.setPress(false); + } + currentImageItem.setPress(true); + //当前选中视频 + if (currentImageItem.isVideo()) { + if (selectConfig.isVideoSinglePickAndAutoComplete()) { + notifyOnSingleImagePickComplete(imageItem); + return; + } + //执行预览视频操作 + videoViewContainerHelper.loadVideoView(mCropContainer, currentImageItem, presenter, uiConfig); + } else { + //加载图片 + loadCropView(); + } + checkStateBtn(); + imageGridAdapter.notifyDataSetChanged(); + touchHelper.transitTopWithAnim(true, pressImageIndex, isShowTransit); + lastPressItem = currentImageItem; + } + + + /** + * 执行选中(取消选中)操作 + * + * @param imageItem 当前item + */ + @Override + public void onCheckItem(ImageItem imageItem, int disableItemCode) { + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, true)) { + return; + } + + //是否拦截当前item的点击事件 + if (isInterceptItemClick(imageItem, true)) { + return; + } + + //如果当前选中列表已经包含了此item,则移除并刷新 + if (selectList.contains(imageItem)) { + removeImageItemFromCropViewList(imageItem); + checkStateBtn(); + } else { + onPressImage(imageItem, false); + addImageItemToCropViewList(imageItem); + } + imageGridAdapter.notifyDataSetChanged(); + } + + @Override + public void folderSelected(ImageSet set, int pos) { + selectImageSet(pos, true); + } + + /** + * 点击选中相册 + * + * @param position 相册position + */ + private void selectImageSet(int position, boolean isTransit) { + ImageSet imageSet = imageSets.get(position); + if (imageSet == null) { + return; + } + for (ImageSet set : imageSets) { + set.isSelected = false; + } + imageSet.isSelected = true; + folderAdapter.notifyDataSetChanged(); + if (titleBar != null) { + titleBar.onImageSetSelected(imageSet); + } + if (bottomBar != null) { + bottomBar.onImageSetSelected(imageSet); + } + if (isTransit) { + toggleFolderList(); + } + loadMediaItemsFromSet(imageSet); + } + + /** + * 加载剪裁view + */ + private void loadCropView() { + mCropView = cropViewContainerHelper.loadCropView(getContext(), currentImageItem, mCropSize, + presenter, new CropViewContainerHelper.onLoadComplete() { + @Override + public void loadComplete() { + checkStateBtn(); + } + }); + resetCropViewSize(mCropView, false); + } + + /** + * 添加当前图片信息到选中列表 + */ + private void addImageItemToCropViewList(ImageItem imageItem) { + if (!selectList.contains(imageItem)) { + selectList.add(imageItem); + } + cropViewContainerHelper.addCropView(mCropView, imageItem); + refreshCompleteState(); + } + + /** + * 从选种列表中移除当前图片信息 + */ + private void removeImageItemFromCropViewList(ImageItem imageItem) { + selectList.remove(imageItem); + cropViewContainerHelper.removeCropView(imageItem); + refreshCompleteState(); + } + + /** + * 检测显示填充、留白、充满和自适应图标 + */ + private void checkStateBtn() { + //选中的第一个item是视频,则隐藏所有按钮 + if (currentImageItem.isVideo()) { + stateBtn.setVisibility(View.GONE); + mTvFullOrGap.setVisibility(View.GONE); + return; + } + //方形图,什么都不显示 + if (currentImageItem.getWidthHeightType() == 0) { + stateBtn.setVisibility(View.GONE); + mTvFullOrGap.setVisibility(View.GONE); + return; + } + //如果已经存在了第一张选中图 + if (selectConfig.hasFirstImageItem()) { + stateBtn.setVisibility(View.GONE); + if (selectConfig.isAssignGapState()) { + if (selectList.size() == 0 || (selectList.get(0) != null + && selectList.get(0).equals(currentImageItem))) { + setImageScaleState(); + } else { + mTvFullOrGap.setVisibility(View.GONE); + if (selectList.get(0).getCropMode() == ImageCropMode.ImageScale_GAP) { + mCropView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + mCropView.setBackgroundColor(Color.WHITE); + } else { + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + mCropView.setBackgroundColor(Color.TRANSPARENT); + } + } + } else { + setImageScaleState(); + } + return; + } + + //当选中图片数量大于0 时 + if (selectList.size() > 0) { + //如果当前选中item就是第一个图片,显示stateBtn + if (currentImageItem == selectList.get(0)) { + stateBtn.setVisibility(View.VISIBLE); + mTvFullOrGap.setVisibility(View.GONE); + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + currentImageItem.setCropMode(cropMode); + } else { + //如果当前选中item不是第一张图片,显示mTvFullOrGap + stateBtn.setVisibility(View.GONE); + setImageScaleState(); + } + } else {//没有选中图片 + stateBtn.setVisibility(View.VISIBLE); + mTvFullOrGap.setVisibility(View.GONE); + } + } + + /** + * 重置剪裁宽高大小 + */ + private void resetCropViewSize(CropImageView view, boolean isShowAnim) { + int height = mCropSize; + int width = mCropSize; + if (cropMode == ImageCropMode.CropViewScale_FIT) { + ImageItem firstImageItem; + //如果已经存在第一张图,则按照第一张图的剪裁模式改变大小 + if (selectConfig.hasFirstImageItem()) { + firstImageItem = selectConfig.getFirstImageItem(); + } else { + //没有已经存在的第一张图信息,则获取选中的第一张图的剪裁模式作为全局的剪裁模式 + if (selectList.size() > 0) { + firstImageItem = selectList.get(0); + } else { + firstImageItem = currentImageItem; + } + } + //如果是宽图,高*3/4 + height = firstImageItem.getWidthHeightType() > 0 ? ((mCropSize * 3) / 4) : mCropSize; + //如果是高图,宽*3/4 + width = firstImageItem.getWidthHeightType() < 0 ? ((mCropSize * 3) / 4) : mCropSize; + } + view.changeSize(isShowAnim, width, height); + } + + + /** + * 第一张图片剪裁区域充满或者自适应(是剪裁区域,不是图片填充和留白) + */ + private void fullOrFit() { + if (cropMode == ImageCropMode.CropViewScale_FIT) { + cropMode = ImageCropMode.CropViewScale_FULL; + stateBtn.setImageDrawable(getResources().getDrawable(uiConfig.getFitIconID())); + } else { + cropMode = ImageCropMode.CropViewScale_FIT; + stateBtn.setImageDrawable(getResources().getDrawable(uiConfig.getFullIconID())); + } + if (currentImageItem != null) { + currentImageItem.setCropMode(cropMode); + } + + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + resetCropViewSize(mCropView, true); + //以下是重置所有选中图片剪裁模式 + cropViewContainerHelper.refreshAllState(currentImageItem, selectList, mInvisibleContainer, + cropMode == ImageCropMode.CropViewScale_FIT, + new CropViewContainerHelper.ResetSizeExecutor() { + @Override + public void resetAllCropViewSize(CropImageView view) { + resetCropViewSize(view, false); + } + }); + } + + + /** + * 设置留白还是填充 + */ + private void setImageScaleState() { + //如果当前模式为自适应模式 + if (cropMode == ImageCropMode.CropViewScale_FIT) { + //如果当前图片和第一张选中图片的宽高类型一样,则不显示留白和充满 + mTvFullOrGap.setVisibility(View.GONE); + } else { + //如果第一张图为充满模式,则不论宽高比(除正方形外),都显示留白和充满 + mTvFullOrGap.setVisibility(View.VISIBLE); + //如果当前已选中该图片,则恢复选择时的填充和留白状态 + if (selectList.contains(currentImageItem)) { + if (currentImageItem.getCropMode() == ImageCropMode.ImageScale_FILL) { + fullState(); + } else if (currentImageItem.getCropMode() == ImageCropMode.ImageScale_GAP) { + gapState(); + } + } else { + //否则都按照默认填充的模式,显示留白提示 + fullState(); + currentImageItem.setCropMode(ImageCropMode.ImageScale_FILL); + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + } + } + } + + /** + * 充满或者留白 + */ + private void fullOrGap() { + //留白 + if (currentImageItem.getCropMode() == ImageCropMode.ImageScale_FILL) { + currentImageItem.setCropMode(ImageCropMode.ImageScale_GAP); + mCropView.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + gapState(); + } else { + //充满 + currentImageItem.setCropMode(ImageCropMode.ImageScale_FILL); + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + fullState(); + } + resetCropViewSize(mCropView, false); + } + + /** + * 留白情况下,显示充满状态 + */ + private void gapState() { + mTvFullOrGap.setText(getString(R.string.picker_str_redBook_full)); + mCropView.setBackgroundColor(Color.WHITE); + mTvFullOrGap.setCompoundDrawablesWithIntrinsicBounds(getResources(). + getDrawable(uiConfig.getFillIconID()), null, null, null); + } + + /** + * 充满情况下,显示留白状态 + */ + private void fullState() { + mTvFullOrGap.setText(getString(R.string.picker_str_redBook_gap)); + mCropView.setBackgroundColor(Color.TRANSPARENT); + mTvFullOrGap.setCompoundDrawablesWithIntrinsicBounds(getResources(). + getDrawable(uiConfig.getGapIconID()), null, null, null); + } + + + /** + * 刷新选中图片列表,执行回调,退出页面 + */ + @Override + protected void notifyPickerComplete() { + //如果当前选择的都是视频 + if (selectList.size() > 0 && selectList.get(0).isVideo()) { + } else { + //正在编辑 + if (mCropView.isEditing()) { + return; + } + //未加载出图片 + if (selectList.contains(currentImageItem) + && (mCropView.getDrawable() == null || + mCropView.getDrawable().getIntrinsicHeight() == 0 || + mCropView.getDrawable().getIntrinsicWidth() == 0)) { + tip(getString(R.string.picker_str_tip_shield)); + return; + } + selectList = cropViewContainerHelper.generateCropUrls(selectList, cropMode); + } + + //如果拦截了完成操作,则执行自定义的拦截操作 + if (!presenter.interceptPickerCompleteClick(getWeakActivity(), selectList, selectConfig)) { + if (null != imageListener) { + imageListener.onImagePickComplete(selectList); + } + } + } + + + @Override + protected void toggleFolderList() { + if (mFolderListRecyclerView.getVisibility() == View.GONE) { + View view = titleBarContainer.getChildAt(0); + if (view == null) { + return; + } + titleBarContainer.removeAllViews(); + titleBarContainer2.removeAllViews(); + titleBarContainer2.addView(view); + + mImageSetMasker.setVisibility(View.VISIBLE); + controllerViewOnTransitImageSet(true); + mFolderListRecyclerView.setVisibility(View.VISIBLE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(getActivity(), + uiConfig.isShowFromBottom() ? R.anim.picker_show2bottom : R.anim.picker_anim_in)); + + } else { + final View view = titleBarContainer2.getChildAt(0); + if (view == null) { + return; + } + mImageSetMasker.setVisibility(View.GONE); + controllerViewOnTransitImageSet(false); + mFolderListRecyclerView.setVisibility(View.GONE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(getActivity(), + uiConfig.isShowFromBottom() ? R.anim.picker_hide2bottom : R.anim.picker_anim_up)); + + titleBarContainer2.postDelayed(new Runnable() { + @Override + public void run() { + titleBarContainer2.removeAllViews(); + titleBarContainer.removeAllViews(); + titleBarContainer.addView(view); + } + }, 300); + } + + } + + @Override + protected void intentPreview(boolean isFolderListPreview, int index) { + + } + + @Override + protected void loadMediaSetsComplete(@Nullable List imageSetList) { + if (imageSetList == null || imageSetList.size() == 0 || + (imageSetList.size() == 1 && imageSetList.get(0).count == 0)) { + tip(getString(R.string.picker_str_tip_media_empty)); + return; + } + this.imageSets = imageSetList; + folderAdapter.refreshData(imageSets); + selectImageSet(0, false); + } + + @Override + protected void loadMediaItemsComplete(@NonNull ImageSet set) { + if (set.imageItems != null && set.imageItems.size() > 0) { + imageItems.clear(); + imageItems.addAll(set.imageItems); + imageGridAdapter.notifyDataSetChanged(); + int firstImageIndex = getCanPressItemPosition(); + if (firstImageIndex < 0) { + return; + } + int index = selectConfig.isShowCamera() ? firstImageIndex + 1 : firstImageIndex; + onClickItem(imageItems.get(firstImageIndex), index, PickerItemDisableCode.NORMAL); + } + } + + /** + * @return 获取第一个有效的item(可以选择的) + */ + private int getCanPressItemPosition() { + for (int i = 0; i < imageItems.size(); i++) { + ImageItem imageItem = imageItems.get(i); + if (imageItem.isVideo() && selectConfig.isVideoSinglePickAndAutoComplete()) { + continue; + } + int code = PickerItemDisableCode.getItemDisableCode(imageItem, selectConfig, + selectList, false); + if (code == PickerItemDisableCode.NORMAL) { + return i; + } + } + return -1; + } + + @Override + protected void refreshAllVideoSet(@Nullable ImageSet allVideoSet) { + if (allVideoSet != null && + allVideoSet.imageItems != null + && allVideoSet.imageItems.size() > 0 + && !imageSets.contains(allVideoSet)) { + imageSets.add(1, allVideoSet); + folderAdapter.refreshData(imageSets); + } + } + + /** + * 相册选择是否打开 + */ + @Override + public boolean onBackPressed() { + if (mFolderListRecyclerView != null && mFolderListRecyclerView.getVisibility() == View.VISIBLE) { + toggleFolderList(); + return true; + } + if (presenter != null && presenter.interceptPickerCancel(getWeakActivity(), selectList)) { + return true; + } + PickerErrorExecutor.executeError(imageListener, PickerError.CANCEL.getCode()); + return false; + } + + + @Override + public void onTakePhotoResult(@Nullable ImageItem imageItem) { + if (imageItem != null) { + addItemInImageSets(imageSets, imageItems, imageItem); + onCheckItem(imageItem, PickerItemDisableCode.NORMAL); + imageGridAdapter.notifyDataSetChanged(); + } + } + + + @Override + protected BaseSelectConfig getSelectConfig() { + return selectConfig; + } + + @Override + protected IPickerPresenter getPresenter() { + return presenter; + } + + @Override + protected PickerUiConfig getUiConfig() { + return uiConfig; + } + + @Override + public void onDestroy() { + //将VideoView所占用的资源释放掉 + if (videoViewContainerHelper != null) { + videoViewContainerHelper.onDestroy(); + } + uiConfig.setPickerUiProvider(null); + uiConfig = null; + presenter = null; + super.onDestroy(); + } + + @Override + public void onResume() { + super.onResume(); + if (videoViewContainerHelper != null) { + videoViewContainerHelper.onResume(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (videoViewContainerHelper != null) { + videoViewContainerHelper.onPause(); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java new file mode 100644 index 0000000..36a8125 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerActivity.java @@ -0,0 +1,129 @@ +package com.remax.visualnovel.widget.imagepicker.activity.multi; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; +import com.remax.visualnovel.widget.imagepicker.data.PickerActivityCallBack; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; + + +import java.util.ArrayList; + +/** + * Description: 多选页 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImagePickerActivity extends AppCompatActivity { + public static final String INTENT_KEY_SELECT_CONFIG = "MultiSelectConfig"; + public static final String INTENT_KEY_PRESENTER = "IPickerPresenter"; + public static final String INTENT_KEY_CURRENT_INDEX = "currentIndex"; + public static final String INTENT_KEY_CURRENT_IMAGE = "currentImage"; + + private MultiImagePickerFragment fragment; + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + + /** + * 跳转微信选择器页面 + * + * @param activity 跳转的activity + * @param selectConfig 配置项 + * @param presenter IMultiPickerBindPresenter + * @param listener 选择回调 + */ + public static void intent(@NonNull Activity activity, @NonNull MultiSelectConfig selectConfig, + @NonNull IPickerPresenter presenter, @NonNull OnImagePickCompleteListener listener) { + if (PViewSizeUtils.onDoubleClick()) { + return; + } + Intent intent = new Intent(activity, MultiImagePickerActivity.class); + intent.putExtra(MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG, selectConfig); + intent.putExtra(MultiImagePickerActivity.INTENT_KEY_PRESENTER, presenter); + PLauncher.init(activity).startActivityForResult(intent, PickerActivityCallBack.create(listener)); + activity.overridePendingTransition(R.anim.act_slide_in_from_bottom, R.anim.no_anim); + } + + /** + * 校验传递数据是否合法 + */ + private boolean isIntentDataFailed() { + selectConfig = (MultiSelectConfig) getIntent().getSerializableExtra(INTENT_KEY_SELECT_CONFIG); + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_PRESENTER); + if (presenter == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return true; + } + if (selectConfig == null) { + PickerErrorExecutor.executeError(this, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return true; + } + return false; + } + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (isIntentDataFailed()) { + return; + } + PickerActivityManager.addActivity(this); + setContentView(R.layout.picker_activity_fragment_wrapper); + setFragment(); + } + + /** + * 填充选择器fragment + */ + private void setFragment() { + fragment = ImagePicker.withMulti(presenter) + .withMultiSelectConfig(selectConfig) + .pickWithFragment(new OnImagePickCompleteListener2() { + @Override + public void onPickFailed(PickerError error) { + PickerErrorExecutor.executeError(MultiImagePickerActivity.this, error.getCode()); + PickerActivityManager.clear(); + } + + @Override + public void onImagePickComplete(ArrayList items) { + ImagePicker.closePickerWithCallback(items); + } + }); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.fragment_container, fragment) + .commit(); + } + + @Override + public void onBackPressed() { + if (fragment != null && fragment.onBackPressed()) { + return; + } + super.onBackPressed(); + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(R.anim.no_anim,R.anim.act_slide_out_from_bottom); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java new file mode 100644 index 0000000..f7e8f0a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/multi/MultiImagePickerFragment.java @@ -0,0 +1,552 @@ +package com.remax.visualnovel.widget.imagepicker.activity.multi; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.app.widget.LoadingDialog; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PBaseLoaderFragment; +import com.remax.visualnovel.widget.imagepicker.activity.preview.MultiImagePreviewActivity; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerFolderAdapter; +import com.remax.visualnovel.widget.imagepicker.adapter.PickerItemAdapter; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.SelectMode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.IReloadExecutor; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.MediaUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.itemdecoration.GridSpaceItemDecoration; + +import java.util.ArrayList; +import java.util.List; + +import timber.log.Timber; + + +/** + * Description: 多选页 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImagePickerFragment extends PBaseLoaderFragment implements View.OnClickListener, + PickerItemAdapter.OnActionResult, IReloadExecutor { + private List imageSets = new ArrayList<>(); + private ArrayList imageItems = new ArrayList<>(); + private RecyclerView mRecyclerView; + private View v_masker; + private TextView mTvTime; + private PickerFolderAdapter mImageSetAdapter; + private RecyclerView mFolderListRecyclerView; + private PickerItemAdapter mAdapter; + private ImageSet currentImageSet; + private FrameLayout titleBarContainer; + private FrameLayout bottomBarContainer; + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + private PickerUiConfig uiConfig; + private FragmentActivity mContext; + private GridLayoutManager layoutManager; + private View view; + + private LoadingDialog loadingDialog; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + view = inflater.inflate(R.layout.picker_activity_multipick, container, false); + return view; + } + + /** + * 校验传递数据是否合法 + */ + private boolean isIntentDataValid() { + Bundle bundle = getArguments(); + if (bundle != null) { + selectConfig = (MultiSelectConfig) bundle.getSerializable(INTENT_KEY_SELECT_CONFIG); + presenter = (IPickerPresenter) bundle.getSerializable(INTENT_KEY_PRESENTER); + if (presenter == null) { + PickerErrorExecutor.executeError(onImagePickCompleteListener, + PickerError.PRESENTER_NOT_FOUND.getCode()); + return false; + } + if (selectConfig == null) { + PickerErrorExecutor.executeError(onImagePickCompleteListener, + PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return false; + } + return true; + } else { + return false; + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mContext = getActivity(); + if (isIntentDataValid()) { + ImagePicker.isOriginalImage = selectConfig.isDefaultOriginal(); + uiConfig = presenter.getUiConfig(getWeakActivity()); + setStatusBar(); + findView(); + if (selectConfig.getLastImageList() != null) { + selectList.addAll(selectConfig.getLastImageList()); + } + loadMediaSets(); + refreshCompleteState(); + } + } + + private OnImagePickCompleteListener onImagePickCompleteListener; + + /** + * 设置图片选择器完成回调 + * + * @param onImagePickCompleteListener 完成回调 + */ + public void setOnImagePickCompleteListener(@NonNull OnImagePickCompleteListener onImagePickCompleteListener) { + this.onImagePickCompleteListener = onImagePickCompleteListener; + } + + /** + * 初始化控件 + */ + private void findView() { + v_masker = view.findViewById(R.id.v_masker); + mRecyclerView = view.findViewById(R.id.mRecyclerView); + mFolderListRecyclerView = view.findViewById(R.id.mSetRecyclerView); + mTvTime = view.findViewById(R.id.tv_time); + mTvTime.setVisibility(View.GONE); + titleBarContainer = view.findViewById(R.id.titleBarContainer); + bottomBarContainer = view.findViewById(R.id.bottomBarContainer); + initAdapters(); + initUI(); + setListener(); + refreshCompleteState(); + } + + private RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + if (mTvTime.getVisibility() == View.VISIBLE) { + mTvTime.setVisibility(View.GONE); + mTvTime.startAnimation(AnimationUtils.loadAnimation(mContext, R.anim.picker_fade_out)); + } + } else { + if (mTvTime.getVisibility() == View.GONE) { + mTvTime.setVisibility(View.VISIBLE); + mTvTime.startAnimation(AnimationUtils.loadAnimation(mContext, R.anim.picker_fade_in)); + } + } + } + + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + if (imageItems != null) + try { + mTvTime.setText(imageItems.get(layoutManager.findFirstVisibleItemPosition()).getTimeFormat()); + } catch (Exception ignored) { + + } + } + }; + + /** + * 初始化UI界面 + */ + private void initUI() { + mRecyclerView.setBackgroundColor(uiConfig.getPickerBackgroundColor()); + titleBar = inflateControllerView(titleBarContainer, true, uiConfig); + bottomBar = inflateControllerView(bottomBarContainer, false, uiConfig); + setFolderListHeight(mFolderListRecyclerView, v_masker, false); + } + + /** + * 初始化监听 + */ + private void setListener() { + v_masker.setOnClickListener(this); +// mRecyclerView.addOnScrollListener(onScrollListener); + mImageSetAdapter.setFolderSelectResult((set, pos) -> selectImageFromSet(pos, true)); + } + + /** + * 初始化相关adapter + */ + private void initAdapters() { + mFolderListRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); + mImageSetAdapter = new PickerFolderAdapter(presenter, uiConfig); + mFolderListRecyclerView.setAdapter(mImageSetAdapter); + mImageSetAdapter.refreshData(imageSets); + + mAdapter = new PickerItemAdapter(selectList, new ArrayList(), selectConfig, presenter, uiConfig); + mAdapter.setHasStableIds(true); + mAdapter.setOnActionResult(this); + layoutManager = new GridLayoutManager(mContext, selectConfig.getColumnCount()); + mRecyclerView.addItemDecoration(new GridSpaceItemDecoration(selectConfig.getColumnCount(), dp(5), dp(5), false)); + if (mRecyclerView.getItemAnimator() instanceof SimpleItemAnimator) { + ((SimpleItemAnimator) mRecyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + mRecyclerView.getItemAnimator().setChangeDuration(0);// 通过设置动画执行时间为0来解决闪烁问题 + } + mRecyclerView.setLayoutManager(layoutManager); + mRecyclerView.setAdapter(mAdapter); + } + + /** + * 选择图片文件夹 + * + * @param position 位置 + */ + private void selectImageFromSet(final int position, boolean isTransit) { + currentImageSet = imageSets.get(position); + if (isTransit) { + toggleFolderList(); + } + for (ImageSet set1 : imageSets) { + set1.isSelected = false; + } + currentImageSet.isSelected = true; + mImageSetAdapter.notifyDataSetChanged(); + if (currentImageSet.isAllMedia()) { + if (selectConfig.isShowCameraInAllMedia()) { + selectConfig.setShowCamera(true); + } + } else { + if (selectConfig.isShowCameraInAllMedia()) { + selectConfig.setShowCamera(false); + } + } + loadMediaItemsFromSet(currentImageSet); + } + + + /** + * 显示或隐藏图片文件夹选项列表 + */ + @Override + protected void toggleFolderList() { + if (mFolderListRecyclerView.getVisibility() == View.GONE) { + controllerViewOnTransitImageSet(true); + v_masker.setVisibility(View.VISIBLE); + mFolderListRecyclerView.setVisibility(View.VISIBLE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(mContext, + uiConfig.isShowFromBottom() ? R.anim.picker_show2bottom : R.anim.picker_anim_in)); + } else { + controllerViewOnTransitImageSet(false); + v_masker.setVisibility(View.GONE); + mFolderListRecyclerView.setVisibility(View.GONE); + mFolderListRecyclerView.setAnimation(AnimationUtils.loadAnimation(mContext, + uiConfig.isShowFromBottom() ? R.anim.picker_hide2bottom : R.anim.picker_anim_up)); + } + } + + @Override + public void onClick(@NonNull View v) { + if (onDoubleClick()) { + return; + } + if (v == v_masker) { + toggleFolderList(); + } + } + + @Override + protected void loadMediaSetsComplete(@Nullable List imageSetList) { + if (imageSetList == null || imageSetList.size() == 0 || + (imageSetList.size() == 1 && imageSetList.get(0).count == 0)) { + tip(getString(R.string.picker_str_tip_media_empty)); + return; + } + this.imageSets = imageSetList; + mImageSetAdapter.refreshData(imageSets); + selectImageFromSet(0, false); + } + + @Override + protected void loadMediaItemsComplete(ImageSet set) { + this.imageItems = set.imageItems; + controllerViewOnImageSetSelected(set); + mAdapter.refreshData(imageItems); + } + + @Override + protected void refreshAllVideoSet(ImageSet allVideoSet) { + if (allVideoSet != null && allVideoSet.imageItems != null + && allVideoSet.imageItems.size() > 0 + && !imageSets.contains(allVideoSet)) { + imageSets.add(1, allVideoSet); + mImageSetAdapter.refreshData(imageSets); + } + } + + @Override + public void onTakePhotoResult(@NonNull ImageItem imageItem) { + //剪裁模式下,直接跳转剪裁页面 + if (selectConfig.getSelectMode() == SelectMode.MODE_CROP) { + intentCrop(imageItem); + return; + } + //单选模式下,直接回调出去 + if (selectConfig.getSelectMode() == SelectMode.MODE_SINGLE) { + notifyOnSingleImagePickComplete(imageItem); + return; + } + //将拍照返回的imageItem手动添加到第一个item上并选中 + addItemInImageSets(imageSets, imageItems, imageItem); + mAdapter.refreshData(imageItems); + mImageSetAdapter.refreshData(imageSets); + onCheckItem(imageItem, PickerItemDisableCode.NORMAL); + } + + @Override + public boolean onBackPressed() { + if (mFolderListRecyclerView != null && mFolderListRecyclerView.getVisibility() == View.VISIBLE) { + toggleFolderList(); + return true; + } + if (presenter != null && presenter.interceptPickerCancel(getWeakActivity(), selectList)) { + return true; + } + PickerErrorExecutor.executeError(onImagePickCompleteListener, PickerError.CANCEL.getCode()); + return false; + } + + + @Override + public void onClickItem(@NonNull ImageItem item, int position, int disableItemCode) { + position = selectConfig.isShowCamera() ? position - 1 : position; + //拍照 + if (position < 0 && selectConfig.isShowCamera()) { + if (!presenter.interceptCameraClick(getWeakActivity(), this)) { + checkTakePhotoOrVideo(); + } + return; + } + + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, false)) { + return; + } + + mRecyclerView.setTag(item); + + //剪裁模式下,直接跳转剪裁 + if (selectConfig.getSelectMode() == SelectMode.MODE_CROP) { + if (item.isGif() || item.isVideo()) { + notifyOnSingleImagePickComplete(item); + } else { + intentCrop(item); + } + return; + } + + //检测是否拦截了item点击 + if (!mAdapter.isPreformClick() && presenter.interceptItemClick(getWeakActivity(), item, selectList, imageItems, + selectConfig, mAdapter, false, this)) { + return; + } + + //如果当前是视频,且视频只能单选,且单选情况下自动回调,则执行回调 + if (item.isVideo() && selectConfig.isVideoSinglePickAndAutoComplete()) { + notifyOnSingleImagePickComplete(item); + return; + } + + //如果当前是单选模式,且单选模式下点击item直接回调,则直接回调 + if (selectConfig.getMaxCount() <= 1 && selectConfig.isSinglePickAutoComplete()) { + notifyOnSingleImagePickComplete(item); + return; + } + + //如果当前是视频,且不支持视频预览,则拦截掉点击 + if (item.isVideo() && !selectConfig.isCanPreviewVideo()) { + tip(getActivity().getString(R.string.picker_str_tip_cant_preview_video)); + return; + } + + //如果开启了预览,则直接跳转预览 + if (selectConfig.isPreview()) { + intentPreview(true, position); + } + } + + @Override + public void onCheckItem(ImageItem imageItem, int disableItemCode) { + if (selectConfig.getSelectMode() == SelectMode.MODE_SINGLE + && selectConfig.getMaxCount() == 1 + && selectList != null && selectList.size() > 0) { + if (selectList.contains(imageItem)) { + selectList.clear(); + } else { + selectList.clear(); + selectList.add(imageItem); + } + } else { + //当前选中item是否不可以点击 + if (interceptClickDisableItem(disableItemCode, true)) { + return; + } + + //检测是否拦截了item点击 + if (!mAdapter.isPreformClick() && presenter.interceptItemClick(getWeakActivity(), imageItem, selectList, imageItems, + selectConfig, mAdapter, true, this)) { + return; + } + + //如果当前选中列表包含此item,则移除,否则添加 + if (selectList.contains(imageItem)) { + selectList.remove(imageItem); + } else { + selectList.add(imageItem); + } + } + mAdapter.notifyDataSetChanged(); + refreshCompleteState(); + } + + /** + * 跳转剪裁页面 + * + * @param imageItem 图片信息 + */ + private void intentCrop(ImageItem imageItem) { + ImagePicker.crop(getActivity(), presenter, selectConfig, imageItem, new OnImagePickCompleteListener() { + @Override + public void onImagePickComplete(ArrayList items) { + selectList.clear(); + selectList.addAll(items); + mAdapter.notifyDataSetChanged(); + notifyPickerComplete(); + } + }); + } + + /** + * 跳转预览 + * + * @param position 默认选中的index + */ + @Override + protected void intentPreview(boolean isClickItem, int position) { + if (!isClickItem && (selectList == null || selectList.size() == 0)) { + return; + } + MultiImagePreviewActivity.intent(getActivity(), isClickItem ? currentImageSet : null, + selectList, selectConfig, presenter, position, new MultiImagePreviewActivity.PreviewResult() { + @Override + public void onResult(ArrayList mImageItems, boolean isCancel) { + if (isCancel) { + reloadPickerWithList(mImageItems); + } else { + selectList.clear(); + selectList.addAll(mImageItems); + mAdapter.notifyDataSetChanged(); + notifyPickerComplete(); + } + } + }); + } + + /** + * 刷新选中图片列表,执行回调,退出页面 + */ + @Override + protected void notifyPickerComplete() { + if (presenter == null || presenter.interceptPickerCompleteClick(getWeakActivity(), selectList, selectConfig)) { + return; + } + if (onImagePickCompleteListener != null) { + if (getActivity() != null) { + loadingDialog = new LoadingDialog(); + loadingDialog.build(getActivity()); + loadingDialog.show(); + } + String temp = MediaUtils.getTempFilePath(); + new Thread(() -> { + for (int i = 0; i < selectList.size(); i++) { + ImageItem imageItem = selectList.get(i); + imageItem.isOriginalImage = ImagePicker.isOriginalImage; + imageItem.path = MediaUtils.copyUriToLocalMedia(getActivity(), imageItem, temp); + //重新获取图片宽高(从图库获取的宽高有错误情况) + if (imageItem.isImage() || imageItem.isGif()) { + int[] imageWidthHeight = PBitmapUtils.getImageWidthHeight(imageItem.path); + Timber.d("MultiImagePickerFragment width:" + imageWidthHeight[0] + " height:" + imageWidthHeight[1]); + imageItem.width = imageWidthHeight[0]; + imageItem.height = imageWidthHeight[1]; + } + } + if (getActivity() != null) { + getActivity().runOnUiThread(() -> { + if (loadingDialog != null) { + loadingDialog.dismiss(); + loadingDialog = null; + } + onImagePickCompleteListener.onImagePickComplete(selectList); + }); + } + }).start(); + } + } + + @Override + protected BaseSelectConfig getSelectConfig() { + return selectConfig; + } + + @Override + protected IPickerPresenter getPresenter() { + return presenter; + } + + @Override + protected PickerUiConfig getUiConfig() { + return uiConfig; + } + + @Override + public void onDestroy() { + uiConfig.setPickerUiProvider(null); + uiConfig = null; + presenter = null; + super.onDestroy(); + } + + @Override + public void reloadPickerWithList(List selectedList) { + selectList.clear(); + selectList.addAll(selectedList); + mAdapter.refreshData(imageItems); + refreshCompleteState(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java new file mode 100644 index 0000000..a756fdf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/preview/MultiImagePreviewActivity.java @@ -0,0 +1,382 @@ +package com.remax.visualnovel.widget.imagepicker.activity.preview; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_CURRENT_INDEX; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.utils.StatusBarUtils; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.MediaItemsDataSource; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.PreviewControllerView; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; + + +/** + * Description: 预览页面,其中包含编辑预览和普通预览 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class MultiImagePreviewActivity extends AppCompatActivity implements MediaItemsDataSource.MediaItemProvider { + static ImageSet currentImageSet; + public static final String INTENT_KEY_SELECT_LIST = "selectList"; + private ViewPager mViewPager; + private ArrayList mSelectList; + private ArrayList mImageList; + private int mCurrentItemPosition = 0; + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + private PickerUiConfig uiConfig; + private WeakReference activityWeakReference; + private DialogInterface dialogInterface; + private PreviewControllerView controllerView; + private TouchImageAdapter touchImageAdapter; + + /** + * 预览回调 + */ + public interface PreviewResult { + void onResult(ArrayList imageItems, boolean isCancel); + } + + /** + * 跳转预览 + * + * @param activity 当前activity + * @param imageSet 当前预览的文件夹信息 + * @param selectList 选中列表 + * @param selectConfig 配置信息 + * @param presenter presenter + * @param position 默认选中项 + * @param result 预览回调 + */ + public static void intent(Activity activity, ImageSet imageSet, + ArrayList selectList, + MultiSelectConfig selectConfig, + IPickerPresenter presenter, + int position, + final PreviewResult result) { + if (activity == null || selectList == null || selectConfig == null + || presenter == null || result == null) { + return; + } + if (imageSet != null) { + currentImageSet = imageSet.copy(); + } + Intent intent = new Intent(activity, MultiImagePreviewActivity.class); + intent.putExtra(INTENT_KEY_SELECT_LIST, selectList); + intent.putExtra(INTENT_KEY_SELECT_CONFIG, selectConfig); + intent.putExtra(INTENT_KEY_PRESENTER, presenter); + intent.putExtra(INTENT_KEY_CURRENT_INDEX, position); + PLauncher.init(activity).startActivityForResult(intent, (resultCode, data) -> { + if (data != null && data.hasExtra(ImagePicker.INTENT_KEY_PICKER_RESULT)) { + ArrayList mList = (ArrayList) data.getSerializableExtra(ImagePicker.INTENT_KEY_PICKER_RESULT); + if (mList != null) { + result.onResult(mList, resultCode == RESULT_CANCELED); + } + } + }); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + activityWeakReference = new WeakReference(this); + if (isIntentDataFailed()) { + finish(); + return; + } + PickerActivityManager.addActivity(this); + setContentView(R.layout.picker_activity_preview); + mViewPager = findViewById(R.id.viewpager); + setUI(); + loadMediaPreviewList(); + } + + /** + * @return 跳转数据是否合法 + */ + private boolean isIntentDataFailed() { + if (getIntent() == null || !getIntent().hasExtra(INTENT_KEY_SELECT_CONFIG) + || !getIntent().hasExtra(INTENT_KEY_PRESENTER)) { + return true; + } + selectConfig = (MultiSelectConfig) getIntent().getSerializableExtra(INTENT_KEY_SELECT_CONFIG); + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_PRESENTER); + mCurrentItemPosition = getIntent().getIntExtra(INTENT_KEY_CURRENT_INDEX, 0); + ArrayList list = (ArrayList) getIntent().getSerializableExtra(INTENT_KEY_SELECT_LIST); + if (list == null || presenter == null) { + return true; + } + mSelectList = new ArrayList(list); + uiConfig = presenter.getUiConfig(activityWeakReference.get()); + return false; + } + + /** + * 执行返回回调 + * + * @param isClickComplete 是否是选中 + */ + private void notifyCallBack(boolean isClickComplete) { + Intent intent = new Intent(); + intent.putExtra(ImagePicker.INTENT_KEY_PICKER_RESULT, mSelectList); + setResult(isClickComplete ? ImagePicker.REQ_PICKER_RESULT_CODE : RESULT_CANCELED, intent); + finish(); + } + + @Override + public void onBackPressed() { + notifyCallBack(false); + } + + /** + * 加载媒体文件夹 + */ + private void loadMediaPreviewList() { + if (currentImageSet == null) { + if (mSelectList != null && mSelectList.size() > 0) { + initViewPager(mSelectList); + } + } else if (currentImageSet.imageItems != null + && currentImageSet.imageItems.size() > 0 + && currentImageSet.imageItems.size() >= currentImageSet.count) { + initViewPager(currentImageSet.imageItems); + } else { + //从媒体库重新扫描 + dialogInterface = getPresenter().showProgressDialog(this, ProgressSceneEnum.loadMediaItem); + ImagePicker.provideMediaItemsFromSet(this, currentImageSet, + selectConfig.getMimeTypes(), this); + } + } + + @Override + public void providerMediaItems(ArrayList imageItems, ImageSet allVideoSet) { + if (dialogInterface != null) { + dialogInterface.dismiss(); + } + initViewPager(imageItems); + } + + /** + * 过滤掉视频 + * + * @param list 所有数据源 + * @return 过滤后的数据源 + */ + private ArrayList filterVideo(ArrayList list) { + if (selectConfig.isCanPreviewVideo()) { + mImageList = new ArrayList<>(list); + return mImageList; + } + mImageList = new ArrayList<>(); + int videoCount = 0; + int nowPosition = 0; + int i = 0; + for (ImageItem imageItem : list) { + if (!imageItem.isVideo() && !imageItem.isGif()) { + mImageList.add(imageItem); + } else { + videoCount++; + } + if (i == mCurrentItemPosition) { + nowPosition = i - videoCount; + } + i++; + } + mCurrentItemPosition = nowPosition; + return mImageList; + } + + /** + * 初始化标题栏 + */ + private void setUI() { + if (uiConfig != null) { + mViewPager.setBackgroundColor(uiConfig.getPreviewBackgroundColor()); + controllerView = uiConfig.getPickerUiProvider().getPreviewControllerView(activityWeakReference.get()); + if (controllerView == null) { + controllerView = new com.remax.visualnovel.widget.imagepicker.views.wrapper.PreviewControllerView(this); + } +// controllerView.setStatusBar(); + StatusBarUtils.INSTANCE.setColor(this, ContextCompat.getColor(this, R.color.glo_color_grey_100)); + StatusBarUtils.INSTANCE.setStatusBarAndNavBarIsLight(this, false); + controllerView.initData(selectConfig, presenter, uiConfig, mSelectList); + if (controllerView.getCompleteView() != null) { + controllerView.getCompleteView().setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (PViewSizeUtils.onDoubleClick()) { + return; + } + notifyCallBack(true); + } + }); + } + FrameLayout mPreviewPanel = findViewById(R.id.mPreviewPanel); + mPreviewPanel.addView(controllerView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + } + } + + /** + * 初始化viewpager + */ + private void initViewPager(ArrayList sourceList) { + mImageList = filterVideo(sourceList); + if (mImageList == null || mImageList.size() == 0) { + getPresenter().tip(this, getString(R.string.picker_str_preview_empty)); + finish(); + return; + } + if (mCurrentItemPosition < 0) { + mCurrentItemPosition = 0; + } + TouchImageAdapter mAdapter = new TouchImageAdapter(getSupportFragmentManager(), mImageList); + mViewPager.setAdapter(mAdapter); + mViewPager.setOffscreenPageLimit(1); + mViewPager.setCurrentItem(mCurrentItemPosition, false); + if (mCurrentItemPosition >= mImageList.size()) { + return; + } + ImageItem item = mImageList.get(mCurrentItemPosition); + controllerView.onPageSelected(mCurrentItemPosition, item, mImageList.size()); + mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + mCurrentItemPosition = position; + if (mCurrentItemPosition >= mImageList.size()) { + return; + } + ImageItem item = mImageList.get(mCurrentItemPosition); + controllerView.onPageSelected(mCurrentItemPosition, item, mImageList.size()); + } + + @Override + public void onPageScrollStateChanged(int state) { + } + }); + } + + /** + * 预览列表点击 + * + * @param imageItem 当前图片 + */ + public void onPreviewItemClick(ImageItem imageItem) { + mViewPager.setCurrentItem(mImageList.indexOf(imageItem), false); + } + + public IPickerPresenter getPresenter() { + return presenter; + } + + public PreviewControllerView getControllerView() { + return controllerView; + } + + static class TouchImageAdapter extends FragmentStatePagerAdapter { + private ArrayList imageItems; + + TouchImageAdapter(FragmentManager fm, ArrayList imageItems) { + super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.imageItems = imageItems; + if (this.imageItems == null) { + this.imageItems = new ArrayList<>(); + } + } + + @Override + public int getCount() { + return imageItems.size(); + } + + @NonNull + @Override + public Fragment getItem(int position) { + return SinglePreviewFragment.newInstance(imageItems.get(position)); + } + } + + @Override + public void finish() { + super.finish(); + PickerActivityManager.removeActivity(this); + if (currentImageSet != null && currentImageSet.imageItems != null) { + currentImageSet.imageItems.clear(); + currentImageSet = null; + } + } + + public static class SinglePreviewFragment extends Fragment { + static final String KEY_URL = "key_url"; + private ImageItem imageItem; + + static SinglePreviewFragment newInstance(ImageItem imageItem) { + SinglePreviewFragment fragment = new SinglePreviewFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(SinglePreviewFragment.KEY_URL, imageItem); + fragment.setArguments(bundle); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle bundle = getArguments(); + if (bundle == null) { + return; + } + imageItem = (ImageItem) bundle.getSerializable(KEY_URL); + } + + PreviewControllerView getControllerView() { + return ((MultiImagePreviewActivity) getActivity()).getControllerView(); + } + + IPickerPresenter getPresenter() { + return ((MultiImagePreviewActivity) getActivity()).getPresenter(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return getControllerView().getItemView(this, imageItem, getPresenter()); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java new file mode 100644 index 0000000..7390df9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/activity/singlecrop/SingleCropActivity.java @@ -0,0 +1,269 @@ +package com.remax.visualnovel.widget.imagepicker.activity.singlecrop; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.os.Parcelable; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.AppCompatImageView; + +import com.remax.visualnovel.extension.ViewExtKt; +import com.remax.visualnovel.R; +import com.remax.visualnovel.utils.StatusBarUtils; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.PickerActivityManager; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropConfig; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropConfigParcelable; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.PickerActivityCallBack; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.helper.DetailImageLoadHelper; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.SingleCropControllerView; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + +import java.io.File; +import java.util.ArrayList; + + +/** + * Description: 图片剪裁页面 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + * 使用文档 :https://github.com/yangpeixing/YImagePicker/wiki/Documentation_3.x + */ +public class SingleCropActivity extends AppCompatActivity { + public static final String INTENT_KEY_CURRENT_IMAGE_ITEM = "currentImageItem"; + private CropImageView cropView; + private AppCompatImageView taskImageView; + private CropConfigParcelable cropConfig; + private IPickerPresenter presenter; + private ImageItem currentImageItem; + + /** + * 跳转单图剪裁 + * + * @param activity 跳转的activity + * @param presenter IPickerPresenter + * @param cropConfig 剪裁配置 + * @param path 需要剪裁的图片的原始路径,可以为Uri相对路径 + * @param listener 剪裁回调 + */ + public static void intentCrop(Activity activity, + IPickerPresenter presenter, + CropConfig cropConfig, + String path, + final OnImagePickCompleteListener listener) { + intentCrop(activity, presenter, cropConfig, ImageItem.withPath(activity, path), listener); + } + + public static void intentCrop(Activity activity, + IPickerPresenter presenter, + CropConfig cropConfig, + ImageItem item, + final OnImagePickCompleteListener listener) { + Intent intent = new Intent(activity, SingleCropActivity.class); + intent.putExtra(INTENT_KEY_PRESENTER, presenter); + intent.putExtra(INTENT_KEY_SELECT_CONFIG, cropConfig.getCropInfo()); + intent.putExtra(INTENT_KEY_CURRENT_IMAGE_ITEM, (Parcelable) item); + PLauncher.init(activity).startActivityForResult(intent, PickerActivityCallBack.create(listener)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getIntent() == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + presenter = (IPickerPresenter) getIntent().getSerializableExtra(INTENT_KEY_PRESENTER); + cropConfig = getIntent().getParcelableExtra(INTENT_KEY_SELECT_CONFIG); + if (presenter == null) { + PickerErrorExecutor.executeError(this, PickerError.PRESENTER_NOT_FOUND.getCode()); + return; + } + if (cropConfig == null) { + PickerErrorExecutor.executeError(this, PickerError.SELECT_CONFIG_NOT_FOUND.getCode()); + return; + } + currentImageItem = getIntent().getParcelableExtra(INTENT_KEY_CURRENT_IMAGE_ITEM); + if (currentImageItem == null || currentImageItem.isEmpty()) { + PickerErrorExecutor.executeError(this, PickerError.CROP_URL_NOT_FOUND.getCode()); + return; + } + + if (new File(currentImageItem.path).length() / 1024 >= 5 * 1024) { + PickerErrorExecutor.executeError(this, PickerError.IMG_SIZE_ERROR.getCode()); + Toast.makeText(this, PickerError.IMG_SIZE_ERROR.getMessage(), Toast.LENGTH_SHORT).show(); + return; + } + + PickerActivityManager.addActivity(this); + setContentView(cropConfig.isSingleCropCutNeedTop() ? R.layout.picker_activity_crop_cover : R.layout.picker_activity_crop); + + //初始化剪裁view + cropView = findViewById(R.id.cropView); + cropView.setMaxScale(3.0f); + cropView.setRotateEnable(true); + cropView.enable(); + cropView.setBounceEnable(!cropConfig.isGap()); + cropView.setCropMargin(cropConfig.getCropRectMargin()); + cropView.setCircle(cropConfig.isCircle()); + cropView.setCropRatio(cropConfig.getCropRatioX(), cropConfig.getCropRatioY()); + + taskImageView = findViewById(R.id.taskFrame); + int taskFrame = cropConfig.getCropTaskFrame(); + taskImageView.setVisibility(View.GONE); + if (taskFrame != 0) { + taskImageView.setVisibility(View.VISIBLE); + ViewExtKt.setSize(taskImageView, taskFrame, taskFrame); + } + + //恢复上一次剪裁属性 + if (cropConfig.getCropRestoreInfo() != null) { + cropView.setRestoreInfo(cropConfig.getCropRestoreInfo()); + } + + //加载图片 + DetailImageLoadHelper.displayDetailImage(true, cropView, presenter, currentImageItem); + setControllerView(); + StatusBarUtils.INSTANCE.setTransparent(this); + } + + /** + * 设置剪裁控制器View + */ + private void setControllerView() { + FrameLayout mCropPanel = findViewById(R.id.mCropPanel); + PickerUiConfig uiConfig = presenter.getUiConfig(this); + SingleCropControllerView cropControllerView = uiConfig.getPickerUiProvider() + .getSingleCropControllerView(this); + mCropPanel.addView(cropControllerView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + cropControllerView.setCropViewParams(cropView, (ViewGroup.MarginLayoutParams) cropView.getLayoutParams()); + cropControllerView.getCompleteView().setOnClickListener(v -> { + if (PViewSizeUtils.onDoubleClick()) { + return; + } + generateCropFile("crop_" + System.currentTimeMillis()); + }); + } + + + /** + * 剪裁完成 + * + * @param cropUrl 剪裁生成的绝对路径 + */ + private void cropComplete(String cropUrl) { + //如果正在编辑中... + if (cropView.isEditing()) { + return; + } + //剪裁异常 + if (cropUrl == null || cropUrl.length() == 0 || cropUrl.startsWith("Exception:")) { + presenter.tip(this, getString(R.string.picker_str_tip_singleCrop_error)); + cropView.setCropRatio(cropConfig.getCropRatioX(), cropConfig.getCropRatioY()); + return; + } + //回调剪裁数据 + // currentImageItem.path = cropUrl; + currentImageItem.mimeType = cropConfig.isNeedPng() ? MimeType.PNG.toString() : MimeType.JPEG.toString(); + currentImageItem.width = cropView.getCropWidth(); + currentImageItem.height = cropView.getCropHeight(); + currentImageItem.setCropUrl(cropUrl); + currentImageItem.setCropRestoreInfo(cropView.getInfo()); + notifyOnImagePickComplete(currentImageItem); + } + + + private DialogInterface dialogInterface; + + /** + * 生成剪裁文件 + * + * @param fileName 图片名称 + */ + public void generateCropFile(final String fileName) { + dialogInterface = presenter.showProgressDialog(this, ProgressSceneEnum.crop); + if (cropConfig.isGap() && !cropConfig.isCircle()) { + cropView.setBackgroundColor(cropConfig.getCropGapBackgroundColor()); + } + currentImageItem.displayName = fileName; + new Thread(() -> { + Bitmap bitmap; + if (cropConfig.isGap()) { + bitmap = cropView.generateCropBitmapFromView(cropConfig.getCropGapBackgroundColor()); + } else { + bitmap = cropView.generateCropBitmap(); + } + final String url = saveBitmapToFile(bitmap, fileName); + runOnUiThread(() -> { + if (dialogInterface != null && !SingleCropActivity.this.isDestroyed()) { + dialogInterface.dismiss(); + } + cropComplete(url); + }); + }).start(); + } + + /** + * 保存bitmap到本地磁盘 + * + * @param bitmap 图片bitmap + * @param fileName 图片名字 + */ + private String saveBitmapToFile(final Bitmap bitmap, final String fileName) { + final String cropUrl; + Bitmap.CompressFormat format = cropConfig.isNeedPng() ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG; + if (cropConfig.isSaveInDCIM()) { + cropUrl = PBitmapUtils.saveBitmapToDCIM(SingleCropActivity.this, bitmap, fileName, format).toString(); + } else { + cropUrl = PBitmapUtils.saveBitmapToFile(SingleCropActivity.this, bitmap, fileName, format); + } + return cropUrl; + } + + + /** + * 回调当前剪裁图片信息 + * + * @param imageItem 剪裁图片信息 + */ + private void notifyOnImagePickComplete(ImageItem imageItem) { + ArrayList list = new ArrayList<>(); + list.add(imageItem); + Intent intent = new Intent(); + intent.putExtra(ImagePicker.INTENT_KEY_PICKER_RESULT, list); + setResult(ImagePicker.REQ_PICKER_RESULT_CODE, intent); + finish(); + } + + @Override + public void finish() { + super.finish(); + if (dialogInterface != null) { + dialogInterface.dismiss(); + } + PickerActivityManager.removeActivity(this); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java new file mode 100644 index 0000000..e6288a8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/MultiPreviewAdapter.java @@ -0,0 +1,117 @@ +package com.remax.visualnovel.widget.imagepicker.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.activity.preview.MultiImagePreviewActivity; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper.ItemTouchHelperAdapter; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.widget.ShowTypeImageView; + +import java.util.ArrayList; +import java.util.Collections; + +/** + * Time: 2019/7/23 10:43 + * Author:ypx + * Description: 多选预览adapter + */ +public class MultiPreviewAdapter extends RecyclerView.Adapter implements ItemTouchHelperAdapter { + private final ArrayList previewList; + private Context context; + private final IPickerPresenter presenter; + private ImageItem previewImageItem; + + @SuppressLint("NotifyDataSetChanged") + public void setPreviewImageItem(ImageItem previewImageItem) { + this.previewImageItem = previewImageItem; + notifyDataSetChanged(); + } + + public MultiPreviewAdapter(ArrayList previewList, IPickerPresenter presenter) { + this.previewList = previewList; + this.presenter = presenter; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + context = parent.getContext(); + ShowTypeImageView imageView = new ShowTypeImageView(context); + ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(dp(48), dp(48)); + params.topMargin = dp(36); + imageView.setLayoutParams(params); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + return new ViewHolder(imageView); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, final int position) { + final ImageItem imageItem = previewList.get(position); + boolean isSelect = previewImageItem != null && previewImageItem.equals(imageItem); + holder.imageView.setSelect(isSelect, ImagePicker.getThemeColor()); + holder.imageView.setTypeFromImage(imageItem); + holder.imageView.setOnClickListener(v -> { + if (context instanceof MultiImagePreviewActivity) { + ((MultiImagePreviewActivity) context).onPreviewItemClick(imageItem); + } + }); + presenter.displayImage(holder.imageView, imageItem, 0, true); + } + + @Override + public int getItemCount() { + return previewList.size(); + } + + public int dp(float dp) { + if (context == null) { + return 0; + } + float density = context.getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + @Override + public boolean onItemMove(int fromPosition, int toPosition) { + try { + if (null == previewList + || fromPosition >= previewList.size() + || toPosition >= previewList.size()) { + return true; + } + Collections.swap(previewList, fromPosition, toPosition); + notifyItemMoved(fromPosition, toPosition); + } catch (Exception e) { + e.printStackTrace(); + } + return true; + } + + @Override + public void onItemDismiss(int position) { + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + static class ViewHolder extends RecyclerView.ViewHolder { + private final ShowTypeImageView imageView; + + ViewHolder(@NonNull View itemView) { + super(itemView); + this.imageView = (ShowTypeImageView) itemView; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java new file mode 100644 index 0000000..aa4c0ff --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerFolderAdapter.java @@ -0,0 +1,105 @@ +package com.remax.visualnovel.widget.imagepicker.adapter; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerFolderItemView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.FolderItemView; + +import java.util.ArrayList; +import java.util.List; + + +/** + * Time: 2018/4/6 10:47 + * Author:yangpeixing + * Description: 文件夹adapter + */ +public class PickerFolderAdapter extends RecyclerView.Adapter { + private List mImageSets = new ArrayList<>(); + private IPickerPresenter presenter; + private PickerUiConfig uiConfig; + + public PickerFolderAdapter(IPickerPresenter presenter, PickerUiConfig uiConfig) { + this.presenter = presenter; + this.uiConfig = uiConfig; + } + + public void refreshData(List folders) { + mImageSets.clear(); + mImageSets.addAll(folders); + notifyDataSetChanged(); + } + + private ImageSet getItem(int i) { + return mImageSets.get(i); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.picker_item_root, parent, false); + return new ViewHolder(view, uiConfig); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, @SuppressLint("RecyclerView") final int position) { + ImageSet imageSet = getItem(position); + PickerFolderItemView pickerFolderItemView = holder.pickerFolderItemView; + pickerFolderItemView.displayCoverImage(imageSet, presenter); + pickerFolderItemView.loadItem(imageSet); + pickerFolderItemView.setOnClickListener(v -> { + if (folderSelectResult != null) { + folderSelectResult.folderSelected(getItem(position), position); + } + }); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public int getItemCount() { + return mImageSets.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + private PickerFolderItemView pickerFolderItemView; + + ViewHolder(View view, PickerUiConfig uiConfig) { + super(view); + pickerFolderItemView = uiConfig.getPickerUiProvider().getFolderItemView(view.getContext()); + if (pickerFolderItemView == null) { + pickerFolderItemView = new FolderItemView(view.getContext()); + } + FrameLayout layout = itemView.findViewById(R.id.mRoot); + int height = pickerFolderItemView.getItemHeight(); + layout.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + height > 0 ? height : ViewGroup.LayoutParams.WRAP_CONTENT)); + layout.removeAllViews(); + layout.addView(pickerFolderItemView); + } + } + + private FolderSelectResult folderSelectResult; + + public void setFolderSelectResult(FolderSelectResult folderSelectResult) { + this.folderSelectResult = folderSelectResult; + } + + public interface FolderSelectResult { + void folderSelected(ImageSet set, int pos); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java new file mode 100644 index 0000000..9b7ea05 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/adapter/PickerItemAdapter.java @@ -0,0 +1,240 @@ +package com.remax.visualnovel.widget.imagepicker.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerItemView; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Description: 多选adapter + *

+ * Author: yangpeixing on 2018/4/6 10:32 + * Date: 2019/2/21 + */ +public class PickerItemAdapter extends RecyclerView.Adapter { + private static final int ITEM_TYPE_CAMERA = 0; + private static final int ITEM_TYPE_NORMAL = 1; + private List images; + //选中图片列表 + private final ArrayList selectList; + private final BaseSelectConfig selectConfig; + private final IPickerPresenter presenter; + private final PickerUiConfig uiConfig; + private boolean isPreformClick = false; + + public PickerItemAdapter(ArrayList selectList, + List images, + BaseSelectConfig selectConfig, + IPickerPresenter presenter, + PickerUiConfig uiConfig) { + this.images = images; + this.selectList = selectList; + this.selectConfig = selectConfig; + this.presenter = presenter; + this.uiConfig = uiConfig; + } + + /** + * 模拟执行选中(取消选中)操作 + * + * @param imageItem 当前item + */ + public void preformCheckItem(ImageItem imageItem) { + if (onActionResult != null) { + isPreformClick = true; + onActionResult.onCheckItem(imageItem, PickerItemDisableCode.NORMAL); + } + } + + /** + * 模拟执行点击操作 + * + * @param imageItem 当前item + * @param position 当前item的position + */ + public void preformClickItem(ImageItem imageItem, int position) { + if (onActionResult != null) { + isPreformClick = true; + onActionResult.onClickItem(imageItem, position, PickerItemDisableCode.NORMAL); + } + } + + @NonNull + @Override + public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ItemViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.picker_item_root, parent, false), + viewType == ITEM_TYPE_CAMERA, selectConfig, presenter, uiConfig); + } + + + @Override + public void onBindViewHolder(@NonNull final ItemViewHolder viewHolder, @SuppressLint("RecyclerView") final int position) { + int itemViewType = getItemViewType(position); + final ImageItem imageItem = getItem(position); + if (itemViewType == ITEM_TYPE_CAMERA || imageItem == null) { + viewHolder.itemView.setOnClickListener(view -> preformClickItem(null, -1)); + return; + } + PickerItemView pickerItemView = viewHolder.pickerItemView; + final int index = selectConfig.isShowCamera() ? position - 1 : position; + pickerItemView.setPosition(index); + pickerItemView.setAdapter(this); + pickerItemView.initItem(imageItem, presenter, selectConfig); + + int indexOfSelectList = selectList.indexOf(imageItem); + boolean isContainsThisItem = indexOfSelectList >= 0; + final int finalDisableCode = PickerItemDisableCode.getItemDisableCode(imageItem, selectConfig, + selectList, isContainsThisItem); + if (pickerItemView.getCheckBoxView() != null) { + pickerItemView.getCheckBoxView().setOnClickListener(view -> { + if (onActionResult != null) { + if (new File(imageItem.path).exists()) { + isPreformClick = false; + onActionResult.onCheckItem(imageItem, finalDisableCode); + } else { + Toast.makeText(CommonApplicationProxy.INSTANCE.getApplication(), CommonApplicationProxy.INSTANCE.getApplication().getString(R.string.file_not_found_hint), + Toast.LENGTH_SHORT).show(); + } + } + }); + } + + pickerItemView.setOnClickListener(view -> { + if (onActionResult != null) { + isPreformClick = false; + onActionResult.onClickItem(imageItem, position, finalDisableCode); + } + }); + + pickerItemView.enableItem(imageItem, indexOfSelectList >= 0, indexOfSelectList,selectConfig.getMaxCount() == selectList.size()); + if (finalDisableCode != PickerItemDisableCode.NORMAL) { + pickerItemView.disableItem(imageItem, finalDisableCode); + } + } + + @Override + public int getItemViewType(int position) { + if (selectConfig.isShowCamera()) { + return position == 0 ? ITEM_TYPE_CAMERA : ITEM_TYPE_NORMAL; + } + return ITEM_TYPE_NORMAL; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getItemCount() { + return selectConfig.isShowCamera() ? images.size() + 1 : images.size(); + } + + private ImageItem getItem(int position) { + if (selectConfig.isShowCamera()) { + if (position == 0) { + return null; + } + return images.get(position - 1); + } else { + return images.get(position); + } + } + + public void refreshData(List items) { + if (items != null && items.size() > 0) { + images = items; + } + notifyDataSetChanged(); + } + + static class ItemViewHolder extends RecyclerView.ViewHolder { + private final PickerItemView pickerItemView; + private final Context context; + + ItemViewHolder(@NonNull View itemView, boolean isCamera, BaseSelectConfig selectConfig, IPickerPresenter presenter, PickerUiConfig uiConfig) { + super(itemView); + context = itemView.getContext(); + FrameLayout layout = itemView.findViewById(R.id.mRoot); + int width = (getScreenWidth() - dp(8)) / selectConfig.getColumnCount(); + PViewSizeUtils.setViewSize(layout, width, 1.00f); + + pickerItemView = uiConfig.getPickerUiProvider().getItemView(context); + layout.removeAllViews(); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + params.bottomMargin = dp(2); + params.topMargin = dp(2); + params.rightMargin = dp(2); + params.leftMargin = dp(2); + if (isCamera) { + layout.addView(pickerItemView.getCameraView(selectConfig, presenter), params); + } else { + layout.addView(pickerItemView, params); + } + } + + int getScreenWidth() { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.widthPixels; + } + + int dp(int dp) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + (float) dp, context.getResources().getDisplayMetrics()); + } + } + + public boolean isPreformClick() { + return isPreformClick; + } + + private OnActionResult onActionResult; + + public void setOnActionResult(OnActionResult onActionResult) { + this.onActionResult = onActionResult; + } + + public interface OnActionResult { + /** + * 点击操作 + * + * @param imageItem 当前item + * @param position 当前item的position + */ + void onClickItem(ImageItem imageItem, int position, int disableItemCode); + + /** + * 执行选中(取消选中)操作 + * + * @param imageItem 当前item + */ + void onCheckItem(ImageItem imageItem, int disableItemCode); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java new file mode 100644 index 0000000..3e4e8c6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageCropMode.java @@ -0,0 +1,27 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +/** + * Description: 图片剪裁模式 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class ImageCropMode { + /** + * 填充模式,按照图片宽度填充到容器(屏幕)宽度 + */ + public static int CropViewScale_FULL = -5; + /** + * 自适应模式,按照图片高度自适应容器高度 + */ + public static int CropViewScale_FIT = -6; + /** + * imageView图片显示模式 填充 + */ + public static int ImageScale_FILL = -7; + + /** + * imageView图片显示模式 留白 + */ + public static int ImageScale_GAP = -8; +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java new file mode 100644 index 0000000..f876c19 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageItem.java @@ -0,0 +1,409 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +import android.app.Activity; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + + +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.Info; + +import java.io.Serializable; + + +/** + * Description: 图片信息 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class ImageItem implements Serializable, Parcelable { + private static final long serialVersionUID = 3429291195776736078L; + //媒体文件ID,可通过此id查询此媒体文件的所有信息 + public long id; + //媒体文件宽 + public int width; + //高 + public int height; + //生成或者更新时间 + public long time; + //时常(仅针对视频) + public long duration; + //文件类型 + public String mimeType; + //更新时间格式化 例如:2019年12月 本周内 等 + public String timeFormat; + //时常格式化 00:00:00 + public String durationFormat; + //是否是视频文件 + private boolean isVideo = false; + //是否是原图 + public boolean isOriginalImage = true; + //文件名 + public String displayName; + + //视频缩略图地址,默认是null,并没有扫描视频缩略图,这里提供此变量便于使用者自己塞入使用 + private String videoImageUri; + // 加入滤镜后的原图图片地址,如果无滤镜返回原图地址,这里提供此变量便于使用者自己app塞入地址使用 + private String imageFilterPath = ""; + + //androidQ上废弃了DATA绝对路径,需要手动拼凑Uri,这里为了兼容大部分项目还没有适配androidQ的情况 + //默认path还是先取绝对路径,取不到或者异常才去取Uri路径 + public String path; + //直接拿到Uri路径,在媒体库里,一定会有Uri路径 + private String uriPath; + // 剪裁后的图片绝对地址(从imageFilterPath 计算出来,已经带了滤镜) + private String cropUrl; + //是否公开 + public boolean openLock; + + //以下是UI上用到的临时变量 + private boolean isSelect = false; + private boolean isPress = false; + private int selectIndex = -1; + private int cropMode = ImageCropMode.ImageScale_FILL; + + private Info cropRestoreInfo; + + public ImageItem() { + } + + public static ImageItem withPath(Context context, String path) { + ImageItem imageItem = new ImageItem(); + imageItem.path = path; + if (imageItem.isUriPath()) { + Uri uri = Uri.parse(path); + imageItem.setUriPath(uri.toString()); + imageItem.mimeType = PBitmapUtils.getMimeTypeFromUri((Activity) context, uri); + if (imageItem.mimeType != null && imageItem.isImage()) { + imageItem.setVideo(MimeType.isVideo(imageItem.mimeType)); + if (imageItem.isImage()) { + int[] size = PBitmapUtils.getImageWidthHeight(context, uri); + imageItem.width = size[0]; + imageItem.height = size[1]; + } + } + } else { + imageItem.mimeType = PBitmapUtils.getMimeTypeFromPath(imageItem.path); + if (imageItem.mimeType != null) { + imageItem.setVideo(MimeType.isVideo(imageItem.mimeType)); + Uri uri; + if (imageItem.isImage()) { + uri = PBitmapUtils.getImageContentUri(context, path); + int[] size = PBitmapUtils.getImageWidthHeight(path); + imageItem.width = size[0]; + imageItem.height = size[1]; + } else { + uri = PBitmapUtils.getVideoContentUri(context, path); + imageItem.duration = PBitmapUtils.getLocalVideoDuration(path); + } + if (uri != null) { + imageItem.setUriPath(uri.toString()); + } + } + } + + return imageItem; + } + + + protected ImageItem(Parcel in) { + id = in.readLong(); + width = in.readInt(); + height = in.readInt(); + time = in.readLong(); + duration = in.readLong(); + mimeType = in.readString(); + timeFormat = in.readString(); + durationFormat = in.readString(); + isVideo = in.readByte() != 0; + videoImageUri = in.readString(); + imageFilterPath = in.readString(); + path = in.readString(); + uriPath = in.readString(); + cropUrl = in.readString(); + isSelect = in.readByte() != 0; + isPress = in.readByte() != 0; + selectIndex = in.readInt(); + cropMode = in.readInt(); + cropRestoreInfo = in.readParcelable(Info.class.getClassLoader()); + isOriginalImage = in.readByte() != 0; + openLock = in.readByte() != 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeInt(width); + dest.writeInt(height); + dest.writeLong(time); + dest.writeLong(duration); + dest.writeString(mimeType); + dest.writeString(timeFormat); + dest.writeString(durationFormat); + dest.writeByte((byte) (isVideo ? 1 : 0)); + dest.writeString(videoImageUri); + dest.writeString(imageFilterPath); + dest.writeString(path); + dest.writeString(uriPath); + dest.writeString(cropUrl); + dest.writeByte((byte) (isSelect ? 1 : 0)); + dest.writeByte((byte) (isPress ? 1 : 0)); + dest.writeInt(selectIndex); + dest.writeInt(cropMode); + dest.writeParcelable(cropRestoreInfo, flags); + dest.writeByte((byte) (isOriginalImage ? 1 : 0)); + dest.writeByte((byte) (openLock ? 1 : 0)); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public ImageItem createFromParcel(Parcel in) { + return new ImageItem(in); + } + + @Override + public ImageItem[] newArray(int size) { + return new ImageItem[size]; + } + }; + + public Info getCropRestoreInfo() { + return cropRestoreInfo; + } + + public void setCropRestoreInfo(Info cropRestoreInfo) { + this.cropRestoreInfo = cropRestoreInfo; + } + + public String getVideoImageUri() { + if (videoImageUri == null || videoImageUri.length() == 0) { + return path; + } + return videoImageUri; + } + + public void setVideoImageUri(String videoImageUri) { + this.videoImageUri = videoImageUri; + } + + public String getImageFilterPath() { + if (imageFilterPath == null || imageFilterPath.length() == 0) { + return path; + } + return imageFilterPath; + } + + public void setImageFilterPath(String imageFilterPath) { + this.imageFilterPath = imageFilterPath; + } + + public boolean isOriginalImage() { + return isOriginalImage; + } + + public void setOriginalImage(boolean originalImage) { + isOriginalImage = originalImage; + } + + public String getLastImageFilterPath() { + return imageFilterPath; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public long getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getDurationFormat() { + return durationFormat; + } + + public void setDurationFormat(String durationFormat) { + this.durationFormat = durationFormat; + } + + public void setVideo(boolean video) { + isVideo = video; + } + + public boolean isGif() { + return MimeType.isGif(mimeType); + } + + public boolean isLongImage() { + return getWidthHeightRatio() > 5 || getWidthHeightRatio() < 0.2; + } + + public boolean isVideo() { + return isVideo; + } + + public boolean isImage() { + return !isVideo; + } + + public int getCropMode() { + return cropMode; + } + + public void setCropMode(int cropMode) { + this.cropMode = cropMode; + } + + public String getCropUrl() { + return cropUrl; + } + + public void setCropUrl(String cropUrl) { + this.cropUrl = cropUrl; + } + + public int getSelectIndex() { + return selectIndex; + } + + public void setSelectIndex(int selectIndex) { + this.selectIndex = selectIndex; + } + + public boolean isPress() { + return isPress; + } + + public void setPress(boolean press) { + isPress = press; + } + + public boolean isSelect() { + return isSelect; + } + + public void setSelect(boolean select) { + isSelect = select; + } + + public String getTimeFormat() { + return timeFormat; + } + + public void setTimeFormat(String timeFormat) { + this.timeFormat = timeFormat; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public boolean isUriPath() { + return path != null && path.contains("content://"); + } + + public Uri getUri() { + if (uriPath != null && uriPath.length() > 0) { + return Uri.parse(uriPath); + } + + if (isUriPath()) { + return Uri.parse(path); + } + + return PBitmapUtils.getContentUri(mimeType, id); + } + + + public float getWidthHeightRatio() { + if (height == 0) { + return 1; + } + return width * 1.00f / (height * 1.00f); + } + + /** + * 获取图片宽高类型,误差0.1 + * + * @return 1:宽图 -1:高图 0:方图 + */ + public int getWidthHeightType() { + if (getWidthHeightRatio() > 1.02f) { + return 1; + } + + if (getWidthHeightRatio() < 0.98f) { + return -1; + } + + return 0; + } + + @Override + public boolean equals(Object o) { + if (path == null) { + return false; + } + try { + ImageItem other = (ImageItem) o; + if (other.path == null) { + return false; + } + return this.path.equalsIgnoreCase(other.path); + } catch (ClassCastException e) { + e.printStackTrace(); + } + return super.equals(o); + } + + public void setUriPath(String uriPath) { + this.uriPath = uriPath; + } + + public ImageItem copy() { + ImageItem newItem = new ImageItem(); + newItem.path = this.path; + newItem.isVideo = this.isVideo; + newItem.duration = this.duration; + newItem.height = this.height; + newItem.width = this.width; + newItem.cropMode = this.cropMode; + newItem.cropUrl = this.cropUrl; + newItem.durationFormat = this.durationFormat; + newItem.id = this.id; + newItem.isPress = false; + newItem.isSelect = false; + newItem.cropRestoreInfo = cropRestoreInfo; + newItem.isOriginalImage = isOriginalImage; + return newItem; + } + + public boolean isOver2KImage() { + return width > 3000 || height > 3000; + } + + public boolean isEmpty() { + return (path == null || path.length() == 0) + && (uriPath == null || uriPath.length() == 0); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java new file mode 100644 index 0000000..7f88b83 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/ImageSet.java @@ -0,0 +1,84 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + + + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * Description: 文件夹信息 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class ImageSet implements Serializable { + public static final String ID_ALL_MEDIA = "-1"; + public static final String ID_ALL_VIDEO = "-2"; + public String id; + public String name; + public String coverPath; + public int count; + public ImageItem cover; + public ArrayList imageItems; + public boolean isSelected = false; + + @Override + public boolean equals(Object o) { + ImageSet other = (ImageSet) o; + if (this == o) { + return true; + } + if (this.id != null && other != null && other.id != null) { + return this.id.equals(other.id); + } + return super.equals(o); + } + + public ImageSet copy() { + ImageSet imageSet = new ImageSet(); + imageSet.name = this.name; + imageSet.coverPath = this.coverPath; + imageSet.cover = this.cover; + imageSet.isSelected = this.isSelected; + imageSet.imageItems = new ArrayList<>(); + if (this.imageItems != null) { + imageSet.imageItems.addAll(this.imageItems); + } + return imageSet; + } + + public ImageSet copy(boolean isFilterVideo) { + ImageSet imageSet = new ImageSet(); + imageSet.name = this.name; + imageSet.coverPath = this.coverPath; + imageSet.cover = this.cover; + imageSet.isSelected = this.isSelected; + imageSet.imageItems = new ArrayList<>(); + if (imageItems != null && imageItems.size() > 0) { + for (ImageItem item : this.imageItems) { + if (isFilterVideo && item.isVideo()) { + continue; + } + ImageItem newItem = item.copy(); + imageSet.imageItems.add(newItem); + } + } + return imageSet; + } + + public static ImageSet allImageSet(String name) { + ImageSet imageSet = new ImageSet(); + imageSet.id = ImageSet.ID_ALL_MEDIA; + imageSet.name = name; + return imageSet; + } + + public boolean isAllMedia() { + return id == null || id.equals(ID_ALL_MEDIA); + } + + public boolean isAllVideo() { + return id != null && id.equals(ID_ALL_VIDEO); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java new file mode 100644 index 0000000..d868b9e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/MimeType.java @@ -0,0 +1,144 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + + +import androidx.collection.ArraySet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Set; + +/** + * MIME Type enumeration to restrict selectable media on the selection activity. Matisse only supports images and + * videos. + *

+ * Good example of mime types Android supports: + * https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/MediaFile.java + */ +public enum MimeType { + + // ============== images ============== + JPEG("image/jpeg", arraySetOf( + "jpg", + "jpeg" + )), + PNG("image/png", arraySetOf( + "png" + )), + GIF("image/gif", arraySetOf( + "gif" + )), + BMP("image/x-ms-bmp", arraySetOf( + "bmp", "x-ms-bmp" + )), + WEBP("image/webp", arraySetOf( + "webp" + )), + + // ============== videos ============== + MPEG("video/mpeg", arraySetOf( + "mpeg", + "mpg" + )), + MP4("video/mp4", arraySetOf( + "mp4" + )), + QUICKTIME("video/quicktime", arraySetOf( + "mov", "quicktime" + )), + THREEGPP("video/3gpp", arraySetOf( + "3gp", + "3gpp" + )), + THREEGPP2("video/3gpp2", arraySetOf( + "3g2", + "3gpp2" + )), + MKV("video/x-matroska", arraySetOf( + "mkv", "x-matroska" + )), + WEBM("video/webm", arraySetOf( + "webm" + )), + TS("video/mp2ts", arraySetOf( + "ts", "mp2ts" + )), + AVI("video/avi", arraySetOf( + "avi" + )); + + private final String mMimeTypeName; + private final Set mExtensions; + + MimeType(String mimeTypeName, Set extensions) { + mMimeTypeName = mimeTypeName; + mExtensions = extensions; + } + + public Set getExtensions() { + return mExtensions; + } + + public String getSuffix() { + return new ArrayList<>(mExtensions).get(0); + } + + public static Set ofAll() { + return EnumSet.allOf(MimeType.class); + } + + public static Set of(MimeType type, MimeType... rest) { + return EnumSet.of(type, rest); + } + + public static Set ofImage() { + return EnumSet.of(JPEG, PNG, GIF, BMP, WEBP); + } + + public static Set ofVideo() { + return EnumSet.of(MPEG, MP4, QUICKTIME, THREEGPP, THREEGPP2, MKV, WEBM, TS, AVI); + } + + public static boolean isImage(String mimeType) { + if (mimeType == null) return false; + return mimeType.startsWith("image"); + } + + public static boolean isVideo(String mimeType) { + if (mimeType == null) return false; + return mimeType.startsWith("video"); + } + + public static boolean isGif(String mimeType) { + if (mimeType == null) return false; + return mimeType.equals(MimeType.GIF.toString()); + } + + private static Set arraySetOf(String... suffixes) { + return new ArraySet<>(Arrays.asList(suffixes)); + } + + @Override + public String toString() { + return mMimeTypeName; + } + + public static ArrayList getMimeTypeList(Set mimeTypes) { + if (mimeTypes == null) { + return new ArrayList<>(); + } + ArrayList mimeList = new ArrayList<>(); + for (MimeType mimeType : mimeTypes) { + if (mimeType.mExtensions != null) { + for (String s : mimeType.mExtensions) { + if (MimeType.isImage(String.valueOf(mimeType))) { + mimeList.add("image/" + s); + } else if (MimeType.isVideo(String.valueOf(mimeType))) { + mimeList.add("video/" + s); + } + } + } + } + return mimeList; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java new file mode 100644 index 0000000..66140c3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerError.java @@ -0,0 +1,59 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +/** + * Time: 2019/10/18 9:18 + * Author:ypx + * Description: 选择器调用失败的回调 + */ +public enum PickerError { + + CANCEL(-26883, ""), + MEDIA_NOT_FOUND(-26884, "not found media files"), + PRESENTER_NOT_FOUND(-26885, "not found presenter,you must be implements IMultiPickerBindPresenter or ICropPickerBindPresenter"), + UI_CONFIG_NOT_FOUND(-26886, "presenter not found uiConfig,please check IMultiPickerBindPresenter or ICropPickerBindPresenter's getUiConfig() method realize"), + SELECT_CONFIG_NOT_FOUND(-26887, "not found selectConfig or cropConfig"), + CROP_URL_NOT_FOUND(-26888, "not found imagePath to crop"), + CROP_EXCEPTION(-26889, "crop exception"), + TAKE_PHOTO_FAILED(-268890, "takePhoto failed"), + OTHER(-26891, "other error"), + MIMETYPES_EMPTY(-268892, "mimeTypes size is 0"), + IMG_SIZE_ERROR(-268893, "The image cannot exceed 10MB"); + + + private int mCode = 0; + private String mMessage = ""; + + PickerError(int code, String msg) { + mCode = code; + mMessage = msg; + } + + public void setMessage(String mMessage) { + this.mMessage = mMessage; + } + + public static PickerError valueOf(int code) { + if (code == CANCEL.getCode()) { + return CANCEL; + } else if (code == PRESENTER_NOT_FOUND.getCode()) { + return PRESENTER_NOT_FOUND; + } else if (code == UI_CONFIG_NOT_FOUND.getCode()) { + return UI_CONFIG_NOT_FOUND; + } else if (code == SELECT_CONFIG_NOT_FOUND.getCode()) { + return SELECT_CONFIG_NOT_FOUND; + } else if (code == MEDIA_NOT_FOUND.getCode()) { + return MEDIA_NOT_FOUND; + } else if (code == IMG_SIZE_ERROR.getCode()) { + return IMG_SIZE_ERROR; + } + return OTHER; + } + + public int getCode() { + return mCode; + } + + public String getMessage() { + return mMessage; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java new file mode 100644 index 0000000..a736f61 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/PickerItemDisableCode.java @@ -0,0 +1,139 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +import android.content.Context; + + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + +import java.util.ArrayList; + +/** + * Time: 2019/10/18 9:18 + * Author:ypx + * Description: 选择器Item不可选中的原因码 + */ +public class PickerItemDisableCode { + public static final int NORMAL = 0; + public static final int DISABLE_IN_SHIELD = 1; + public static final int DISABLE_OVER_MAX_COUNT = 2; + public static final int DISABLE_ONLY_SELECT_IMAGE = 3; + public static final int DISABLE_ONLY_SELECT_VIDEO = 4; + public static final int DISABLE_VIDEO_OVER_MAX_DURATION = 5; + public static final int DISABLE_VIDEO_LESS_MIN_DURATION = 6; + public static final int DISABLE_VIDEO_ONLY_SINGLE_PICK = 7; + + public static String getMessageFormCode(Context context, int code, IPickerPresenter presenter, BaseSelectConfig selectConfig) { + String message = ""; + switch (code) { + case DISABLE_IN_SHIELD: + message = context.getString(R.string.picker_str_tip_shield); + break; + case DISABLE_OVER_MAX_COUNT: + presenter.overMaxCountTip(context, selectConfig.getMaxCount()); + message = ""; + break; + case DISABLE_ONLY_SELECT_IMAGE: + message = context.getString(R.string.picker_str_tip_only_select_image); + break; + case DISABLE_ONLY_SELECT_VIDEO: + message = context.getString(R.string.picker_str_tip_only_select_video); + break; + case DISABLE_VIDEO_OVER_MAX_DURATION: + message = context.getString(R.string.picker_str_str_video_over_max_duration) + + selectConfig.getMaxVideoDurationFormat(context); + break; + case DISABLE_VIDEO_LESS_MIN_DURATION: + message = context.getString(R.string.picker_str_tip_video_less_min_duration) + + selectConfig.getMinVideoDurationFormat(context); + break; + case DISABLE_VIDEO_ONLY_SINGLE_PICK: + message = context.getString(R.string.picker_str_tip_only_select_one_video); + break; + } + return message; + } + + + public static int getItemDisableCode(ImageItem imageItem, BaseSelectConfig selectConfig, + ArrayList selectList, + boolean isContainsThisItem) { + boolean isItemEnable = true; + int disableCode = PickerItemDisableCode.NORMAL; + + //如果在屏蔽列表中,代表不可选择 + if (selectConfig.isShieldItem(imageItem)) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_IN_SHIELD; + } + + //如果是视频item + if (imageItem.isVideo()) { + //如果只能选择图片和视频类型一种,并且当前已经选择了图片,则该视频不可以选中 + if (isItemEnable + && selectConfig.isSinglePickImageOrVideoType() + && selectedFirstItemIsImage(selectList)) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_ONLY_SELECT_IMAGE; + } + //视频时长不符合选择条件 + else if (isItemEnable + && imageItem.duration > selectConfig.getMaxVideoDuration()) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_VIDEO_OVER_MAX_DURATION; + } else if (isItemEnable + && imageItem.duration < selectConfig.getMinVideoDuration()) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_VIDEO_LESS_MIN_DURATION; + } + //如果视频只能单选并且已经选过视频 + else if (isItemEnable + && selectConfig.isVideoSinglePick() + && isSelectedListContainsVideo(selectList) + && !isContainsThisItem) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_VIDEO_ONLY_SINGLE_PICK; + } + } + //如果是图片item + else { + //如果只能选择图片和视频类型一种,并且当前已经选择了视频,则该图片不可以选中 + if (selectConfig.isSinglePickImageOrVideoType() + && selectedFirstItemIsVideo(selectList)) { + isItemEnable = false; + disableCode = PickerItemDisableCode.DISABLE_ONLY_SELECT_VIDEO; + } + } + + //已经超过最大选中数量 + if (isItemEnable && hasSelectedList(selectList) && selectList.size() >= selectConfig.getMaxCount() + && !isContainsThisItem) { + disableCode = PickerItemDisableCode.DISABLE_OVER_MAX_COUNT; + } + + return disableCode; + } + + private static boolean selectedFirstItemIsVideo(ArrayList selectList) { + return hasSelectedList(selectList) && selectList.get(0) != null && selectList.get(0).isVideo(); + } + + private static boolean selectedFirstItemIsImage(ArrayList selectList) { + return hasSelectedList(selectList) && selectList.get(0) != null && !selectList.get(0).isVideo(); + } + + private static boolean hasSelectedList(ArrayList selectList) { + return selectList != null && selectList.size() > 0; + } + + + private static boolean isSelectedListContainsVideo(ArrayList selectList) { + for (ImageItem imageItem : selectList) { + if (imageItem.isVideo()) { + return true; + } + } + return false; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java new file mode 100644 index 0000000..0933d5e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/SelectMode.java @@ -0,0 +1,13 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +/** + * Description: 图片选择模式 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public interface SelectMode { + int MODE_SINGLE = 0; + int MODE_MULTI = 1; + int MODE_CROP = 3; +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java new file mode 100644 index 0000000..9dc6f04 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/UriPathInfo.java @@ -0,0 +1,62 @@ +package com.remax.visualnovel.widget.imagepicker.bean; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import java.io.Serializable; + +public class UriPathInfo implements Serializable, Parcelable { + public Uri uri; + public String absolutePath; + + public UriPathInfo(Uri uri, String absolutePath) { + this.uri = uri; + this.absolutePath = absolutePath; + } + + protected UriPathInfo(Parcel in) { + uri = in.readParcelable(Uri.class.getClassLoader()); + absolutePath = in.readString(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public UriPathInfo createFromParcel(Parcel in) { + return new UriPathInfo(in); + } + + @Override + public UriPathInfo[] newArray(int size) { + return new UriPathInfo[size]; + } + }; + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(uri, flags); + dest.writeString(absolutePath); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java new file mode 100644 index 0000000..78040f6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/BaseSelectConfig.java @@ -0,0 +1,195 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + +import android.content.Context; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Set; + +/** + * Time: 2019/9/30 11:05 + * Author:ypx + * Description: 配置类基类 + */ +public class BaseSelectConfig implements Serializable { + private int maxCount; + private int minCount; + private long minVideoDuration = 0; + private long maxVideoDuration = 1200000000L; + private int columnCount = 4; + private boolean isShowCamera; + private boolean isShowCameraInAllMedia; + private boolean isVideoSinglePick = true; + private boolean isShowVideo = true; + private boolean isShowImage = true; + private boolean isLoadGif = false; + + private boolean isSinglePickAutoComplete = false; + + /** + * 图片和视频只能选择一个 + */ + private boolean isSinglePickImageOrVideoType = false; + private Set mimeTypes = MimeType.ofAll(); + private ArrayList shieldImageList = new ArrayList<>(); + + public boolean isShowCameraInAllMedia() { + return isShowCameraInAllMedia; + } + + public void setShowCameraInAllMedia(boolean showCameraInAllMedia) { + isShowCameraInAllMedia = showCameraInAllMedia; + } + + public ArrayList getShieldImageList() { + return shieldImageList; + } + + public void setShieldImageList(ArrayList shieldImageList) { + this.shieldImageList = shieldImageList; + } + + public boolean isSinglePickImageOrVideoType() { + return isSinglePickImageOrVideoType; + } + + public void setSinglePickImageOrVideoType(boolean singlePickImageOrVideoType) { + isSinglePickImageOrVideoType = singlePickImageOrVideoType; + } + + public int getMinCount() { + return minCount; + } + + public void setMinCount(int minCount) { + this.minCount = minCount; + } + + public long getMinVideoDuration() { + return minVideoDuration; + } + + public void setMinVideoDuration(long minVideoDuration) { + this.minVideoDuration = minVideoDuration; + } + + public long getMaxVideoDuration() { + return maxVideoDuration; + } + + public String getMaxVideoDurationFormat(Context context) { + return PDateUtil.formatTime(context, maxVideoDuration); + } + + public String getMinVideoDurationFormat(Context context) { + return PDateUtil.formatTime(context, minVideoDuration); + } + + public void setMaxVideoDuration(long maxVideoDuration) { + this.maxVideoDuration = maxVideoDuration; + } + + public int getColumnCount() { + return columnCount; + } + + public void setColumnCount(int columnCount) { + this.columnCount = columnCount; + } + + public int getMaxCount() { + return maxCount; + } + + public void setMaxCount(int maxCount) { + this.maxCount = maxCount; + } + + public boolean isShowCamera() { + return isShowCamera; + } + + public void setShowCamera(boolean showCamera) { + isShowCamera = showCamera; + } + + public boolean isVideoSinglePick() { + return isVideoSinglePick; + } + + public void setVideoSinglePick(boolean videoSinglePick) { + isVideoSinglePick = videoSinglePick; + } + + public boolean isShowVideo() { + return isShowVideo; + } + + public void setShowVideo(boolean showVideo) { + isShowVideo = showVideo; + } + + public boolean isShowImage() { + return isShowImage; + } + + public boolean isOnlyShowImage() { + return isShowImage && !isShowVideo; + } + + public boolean isOnlyShowVideo() { + return isShowVideo && !isShowImage; + } + + public void setShowImage(boolean showImage) { + isShowImage = showImage; + } + + public boolean isLoadGif() { + return isLoadGif; + } + + public void setLoadGif(boolean loadGif) { + isLoadGif = loadGif; + } + + public Set getMimeTypes() { + return mimeTypes; + } + + public void setMimeTypes(Set mimeTypes) { + this.mimeTypes = mimeTypes; + } + + public boolean isSinglePickAutoComplete() { + return isSinglePickAutoComplete; + } + + public void setSinglePickAutoComplete(boolean singlePickAutoComplete) { + isSinglePickAutoComplete = singlePickAutoComplete; + } + + public boolean isVideoSinglePickAndAutoComplete() { + return isVideoSinglePick() && isSinglePickAutoComplete(); + } + + /** + * 是否屏蔽某个URL + */ + public boolean isShieldItem(ImageItem imageItem) { + if (shieldImageList == null || shieldImageList.size() == 0) { + return false; + } + for (ImageItem item : shieldImageList) { + if (item.equals(imageItem)) { + return true; + } + } + return false; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java new file mode 100644 index 0000000..1f1b080 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfig.java @@ -0,0 +1,166 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + +import android.graphics.Color; +import android.util.Size; + +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.Info; + + +/** + * Time: 2019/10/27 18:53 + * Author:ypx + * Description: 单图剪裁配置类 + */ +public class CropConfig extends BaseSelectConfig { + //充满式剪裁 + public static final int STYLE_FILL = 1; + //留白式剪裁 + public static final int STYLE_GAP = 2; + private int cropRatioX = 1; + private int cropRatioY = 1; + private boolean isCircle = false; + private int cropRectMargin = 0; + private int cropTaskFrame = 0; + + private int cropStyle = STYLE_FILL; + private int cropGapBackgroundColor = Color.BLACK; + + private boolean saveInDCIM = false; + + private Size outPutSize; + private long maxOutPutByte; + private boolean isLessOriginalByte; + private Info cropRestoreInfo; + private boolean isSingleCropCutNeedTop = false; + + public boolean isSingleCropCutNeedTop() { + return isSingleCropCutNeedTop; + } + + public void setSingleCropCutNeedTop(boolean singleCropCutNeedTop) { + isSingleCropCutNeedTop = singleCropCutNeedTop; + } + + public Size getOutPutSize() { + return outPutSize; + } + + public void setOutPutSize(Size outPutSize) { + this.outPutSize = outPutSize; + } + + public long getMaxOutPutByte() { + return maxOutPutByte; + } + + public void setMaxOutPutByte(long maxOutPutByte) { + this.maxOutPutByte = maxOutPutByte; + } + + public boolean isLessOriginalByte() { + return isLessOriginalByte; + } + + public void setLessOriginalByte(boolean lessOriginalByte) { + isLessOriginalByte = lessOriginalByte; + } + + public Info getCropRestoreInfo() { + return cropRestoreInfo; + } + + public void setCropRestoreInfo(Info cropRestoreInfo) { + this.cropRestoreInfo = cropRestoreInfo; + } + + public boolean isSaveInDCIM() { + return saveInDCIM; + } + + public void saveInDCIM(boolean saveInDCIM) { + this.saveInDCIM = saveInDCIM; + } + + public int getCropStyle() { + return cropStyle; + } + + public void setCropStyle(int cropStyle) { + this.cropStyle = cropStyle; + } + + public int getCropGapBackgroundColor() { + return cropGapBackgroundColor; + } + + public void setCropGapBackgroundColor(int cropGapBackgroundColor) { + this.cropGapBackgroundColor = cropGapBackgroundColor; + } + + public boolean isCircle() { + return isCircle; + } + + public void setCircle(boolean circle) { + isCircle = circle; + } + + + public int getCropRectMargin() { + return cropRectMargin; + } + + public void setCropRectMargin(int cropRectMargin) { + this.cropRectMargin = cropRectMargin; + } + + public int getCropTaskFrame() { + return cropTaskFrame; + } + + public void setCropTaskFrame(int cropTaskFrame) { + this.cropTaskFrame = cropTaskFrame; + } + + public int getCropRatioX() { + if (isCircle) { + return 1; + } + return cropRatioX; + } + + public void setCropRatio(int x, int y) { + this.cropRatioX = x; + this.cropRatioY = y; + } + + public int getCropRatioY() { + if (isCircle) { + return 1; + } + return cropRatioY; + } + + public boolean isGap() { + return cropStyle == STYLE_GAP; + } + + public boolean isNeedPng() { + return isCircle || getCropGapBackgroundColor() == Color.TRANSPARENT; + } + + public CropConfigParcelable getCropInfo() { + CropConfigParcelable parcelable = new CropConfigParcelable(); + parcelable.setCircle(isCircle); + parcelable.setCropGapBackgroundColor(getCropGapBackgroundColor()); + parcelable.setCropRatio(getCropRatioX(), getCropRatioY()); + parcelable.setCropRectMargin(getCropRectMargin()); + parcelable.setCropTaskFrame(getCropTaskFrame()); + parcelable.setCropRestoreInfo(getCropRestoreInfo()); + parcelable.setCropStyle(getCropStyle()); + parcelable.setLessOriginalByte(isLessOriginalByte()); + parcelable.setMaxOutPutByte(getMaxOutPutByte()); + parcelable.saveInDCIM(isSaveInDCIM()); + return parcelable; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java new file mode 100644 index 0000000..6811eed --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropConfigParcelable.java @@ -0,0 +1,212 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + +import android.graphics.Color; +import android.os.Parcel; +import android.os.Parcelable; + +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.Info; + +/** + * Time: 2019/10/27 18:53 + * Author:ypx + * Description: 单图剪裁配置类 + */ +public class CropConfigParcelable implements Parcelable { + //充满式剪裁 + public static final int STYLE_FILL = 1; + //留白式剪裁 + public static final int STYLE_GAP = 2; + private int cropRatioX = 1; + private int cropRatioY = 1; + private boolean isCircle = false; + private int cropRectMargin = 0; + private int cropTaskFrame = 0; + private int cropStyle = STYLE_FILL; + private int cropGapBackgroundColor = Color.BLACK; + + private boolean saveInDCIM = false; + + // private Size outPutSize; + private long maxOutPutByte; + private boolean isLessOriginalByte; + private Info cropRestoreInfo; + private boolean isSingleCropCutNeedTop = false; + + public boolean isSingleCropCutNeedTop() { + return isSingleCropCutNeedTop; + } + + public void setSingleCropCutNeedTop(boolean singleCropCutNeedTop) { + isSingleCropCutNeedTop = singleCropCutNeedTop; + } + + protected CropConfigParcelable() { + + } + + protected CropConfigParcelable(Parcel in) { + cropRatioX = in.readInt(); + cropRatioY = in.readInt(); + isCircle = in.readByte() != 0; + cropRectMargin = in.readInt(); + cropTaskFrame = in.readInt(); + cropStyle = in.readInt(); + cropGapBackgroundColor = in.readInt(); + saveInDCIM = in.readByte() != 0; + maxOutPutByte = in.readLong(); + isLessOriginalByte = in.readByte() != 0; + cropRestoreInfo = in.readParcelable(Info.class.getClassLoader()); + isSingleCropCutNeedTop=in.readByte() != 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public CropConfigParcelable createFromParcel(Parcel in) { + return new CropConfigParcelable(in); + } + + @Override + public CropConfigParcelable[] newArray(int size) { + return new CropConfigParcelable[size]; + } + }; + + + public long getMaxOutPutByte() { + return maxOutPutByte; + } + + public void setMaxOutPutByte(long maxOutPutByte) { + this.maxOutPutByte = maxOutPutByte; + } + + public boolean isLessOriginalByte() { + return isLessOriginalByte; + } + + public void setLessOriginalByte(boolean lessOriginalByte) { + isLessOriginalByte = lessOriginalByte; + } + + public Info getCropRestoreInfo() { + return cropRestoreInfo; + } + + public void setCropRestoreInfo(Info cropRestoreInfo) { + this.cropRestoreInfo = cropRestoreInfo; + } + + public boolean isSaveInDCIM() { + return saveInDCIM; + } + + public void saveInDCIM(boolean saveInDCIM) { + this.saveInDCIM = saveInDCIM; + } + + public int getCropStyle() { + return cropStyle; + } + + public void setCropStyle(int cropStyle) { + this.cropStyle = cropStyle; + } + + public int getCropGapBackgroundColor() { + return cropGapBackgroundColor; + } + + public void setCropGapBackgroundColor(int cropGapBackgroundColor) { + this.cropGapBackgroundColor = cropGapBackgroundColor; + } + + public boolean isCircle() { + return isCircle; + } + + public void setCircle(boolean circle) { + isCircle = circle; + } + + + public int getCropRectMargin() { + return cropRectMargin; + } + + public void setCropRectMargin(int cropRectMargin) { + this.cropRectMargin = cropRectMargin; + } + + public int getCropTaskFrame() { + return cropTaskFrame; + } + + public void setCropTaskFrame(int cropTaskFrame) { + this.cropTaskFrame = cropTaskFrame; + } + + public int getCropRatioX() { + if (isCircle) { + return 1; + } + return cropRatioX; + } + + public void setCropRatio(int x, int y) { + this.cropRatioX = x; + this.cropRatioY = y; + } + + public int getCropRatioY() { + if (isCircle) { + return 1; + } + return cropRatioY; + } + + public boolean isGap() { + return cropStyle == STYLE_GAP; + } + + public boolean isNeedPng() { + return isCircle || getCropGapBackgroundColor() == Color.TRANSPARENT; + } + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(cropRatioX); + dest.writeInt(cropRatioY); + dest.writeByte((byte) (isCircle ? 1 : 0)); + dest.writeInt(cropRectMargin); + dest.writeInt(cropTaskFrame); + dest.writeInt(cropStyle); + dest.writeInt(cropGapBackgroundColor); + dest.writeByte((byte) (saveInDCIM ? 1 : 0)); + dest.writeLong(maxOutPutByte); + dest.writeByte((byte) (isLessOriginalByte ? 1 : 0)); + dest.writeParcelable(cropRestoreInfo, flags); + dest.writeByte((byte) (isSingleCropCutNeedTop ? 1 : 0)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java new file mode 100644 index 0000000..b6eb8a0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/CropSelectConfig.java @@ -0,0 +1,39 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +/** + * Time: 2019/9/3 13:46 + * Author:ypx + * Description:小红书剪裁配置类 + */ +public class CropSelectConfig extends BaseSelectConfig { + private ImageItem firstImageItem; + private boolean assignGapState = false; + + public CropSelectConfig() { + setSinglePickImageOrVideoType(true); + } + + public ImageItem getFirstImageItem() { + return firstImageItem; + } + + + public void setFirstImageItem(ImageItem firstImageItem) { + this.firstImageItem = firstImageItem; + } + + public boolean hasFirstImageItem() { + return firstImageItem != null && firstImageItem.width > 0 && firstImageItem.height > 0; + } + + public boolean isAssignGapState() { + return assignGapState; + } + + public void setAssignGapState(boolean assignGapState) { + this.assignGapState = assignGapState; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java new file mode 100644 index 0000000..5295978 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/bean/selectconfig/MultiSelectConfig.java @@ -0,0 +1,91 @@ +package com.remax.visualnovel.widget.imagepicker.bean.selectconfig; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.SelectMode; + +import java.util.ArrayList; + +/** + * Description: 多选配置项 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class MultiSelectConfig extends CropConfig { + private boolean isShowOriginalCheckBox; + private boolean isDefaultOriginal; + private boolean isCanEditPic; + private boolean isCanPreviewVideo = true; + private boolean isPreview = true; + + private int selectMode = SelectMode.MODE_MULTI; + private ArrayList lastImageList = new ArrayList<>(); + + public boolean isPreview() { + return isPreview; + } + + public void setPreview(boolean preview) { + isPreview = preview; + } + + + public ArrayList getLastImageList() { + return lastImageList; + } + + public void setLastImageList(ArrayList lastImageList) { + this.lastImageList = lastImageList; + } + + public int getSelectMode() { + return selectMode; + } + + public void setSelectMode(int selectMode) { + this.selectMode = selectMode; + } + + public boolean isShowOriginalCheckBox() { + return isShowOriginalCheckBox; + } + + public void setShowOriginalCheckBox(boolean showOriginalCheckBox) { + isShowOriginalCheckBox = showOriginalCheckBox; + } + + public boolean isDefaultOriginal() { + return isDefaultOriginal; + } + + public void setDefaultOriginal(boolean defaultOriginal) { + isDefaultOriginal = defaultOriginal; + } + + public boolean isCanEditPic() { + return isCanEditPic; + } + + public void setCanEditPic(boolean canEditPic) { + isCanEditPic = canEditPic; + } + + /** + * 是否是之前选中过的 + */ + public boolean isLastItem(ImageItem imageItem) { + if (lastImageList == null || lastImageList.size() == 0) { + return false; + } + return lastImageList.contains(imageItem); + } + + public boolean isCanPreviewVideo() { + return isCanPreviewVideo; + } + + public void setCanPreviewVideo(boolean canPreviewVideo) { + isCanPreviewVideo = canPreviewVideo; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java new file mode 100644 index 0000000..9ab2905 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/CropPickerBuilder.java @@ -0,0 +1,263 @@ +package com.remax.visualnovel.widget.imagepicker.builder; + +import android.app.Activity; +import android.os.Bundle; + + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropActivity; +import com.remax.visualnovel.widget.imagepicker.activity.crop.MultiImageCropFragment; +import com.remax.visualnovel.widget.imagepicker.bean.ImageCropMode; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.CropSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + + +/** + * Description: 小红书剪裁选择器构造类 + *

+ * Author: peixing.yang + * Date: 2019/2/28 + */ +public class CropPickerBuilder { + private CropSelectConfig selectConfig; + private IPickerPresenter presenter; + + public CropPickerBuilder(IPickerPresenter presenter) { + this.presenter = presenter; + this.selectConfig = new CropSelectConfig(); + } + + /** + * @param columnCount 设置列数 + */ + public CropPickerBuilder setColumnCount(int columnCount) { + selectConfig.setColumnCount(columnCount); + return this; + } + + /** + * @param duration 设置视频可选择的最大时长 + */ + public CropPickerBuilder setMaxVideoDuration(long duration) { + this.selectConfig.setMaxVideoDuration(duration); + return this; + } + + /** + * @param duration 设置视频可选择的最小时长 + */ + public CropPickerBuilder setMinVideoDuration(long duration) { + this.selectConfig.setMinVideoDuration(duration); + return this; + } + + /** + * @param maxCount 选中数量限制 + */ + public CropPickerBuilder setMaxCount(int maxCount) { + selectConfig.setMaxCount(maxCount); + return this; + } + + /** + * @param isSinglePick 是否单选视频,如果设置为true,则点击item会走presenter的clickVideo方法, + * 设置为false,则触发视频多选和预览模式 + */ + public CropPickerBuilder setVideoSinglePick(boolean isSinglePick) { + selectConfig.setVideoSinglePick(isSinglePick); + return this; + } + + /** + * @param isShowCamera 是否显示拍照item + */ + public CropPickerBuilder showCamera(boolean isShowCamera) { + selectConfig.setShowCamera(isShowCamera); + return this; + } + + /** + * 设置需要加载的文件类型 + * + * @param mimeTypes 需要加载的文件类型集合 + */ + public CropPickerBuilder mimeTypes(Set mimeTypes) { + if (mimeTypes == null || mimeTypes.size() == 0) { + return this; + } + selectConfig.setMimeTypes(mimeTypes); + return this; + } + + /** + * 设置文件加载类型 + * + * @param mimeTypes 文件类型数组 + */ + public CropPickerBuilder mimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return mimeTypes(mimeTypeSet); + } + + /** + * 设置需要过滤掉的文件类型 + * + * @param mimeTypes 需要过滤的文件类型数组 + */ + public CropPickerBuilder filterMimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return filterMimeTypes(mimeTypeSet); + } + + /** + * 设置需要过滤掉的文件类型 + * + * @param mimeTypes 文件类型集合 + */ + public CropPickerBuilder filterMimeTypes(Set mimeTypes) { + selectConfig.getMimeTypes().removeAll(mimeTypes); + return this; + } + + /** + * @param isAutoComplete 设置单选模式下是否点击item就自动回调 + */ + public CropPickerBuilder setSinglePickWithAutoComplete(boolean isAutoComplete) { + selectConfig.setSinglePickAutoComplete(isAutoComplete); + return this; + } + + //--------------- 以下是小红书剪裁特有属性 ------------------------------------- + + /** + * 在没有指定setFirstImageItem时,使用这个方法传入当前的第一张图片的宽高信息, + * 会生成一个新的FirstImageItem,其剪裁模式根据图片宽高决定,如果已经指定了FirstImageItem,则该方法无效 + * + * @param width 第一张图片的宽 + * @param height 第一张图片的高 + */ + public CropPickerBuilder setFirstImageItemSize(int width, int height) { + if (width == 0 || height == 0 || selectConfig.hasFirstImageItem()) { + return this; + } + ImageItem firstImageItem = new ImageItem(); + firstImageItem.setVideo(false); + firstImageItem.width = width; + firstImageItem.height = height; + if (Math.abs(width - height) < 5) { + firstImageItem.setCropMode(ImageCropMode.CropViewScale_FULL); + } else { + firstImageItem.setCropMode(ImageCropMode.CropViewScale_FIT); + } + return setFirstImageItem(firstImageItem); + } + + /** + * 强制指定留白模式,即一打开只有留白模式 + * + * @param isAssignGap 指定留白 + */ + public CropPickerBuilder assignGapState(boolean isAssignGap) { + selectConfig.setAssignGapState(isAssignGap); + if (isAssignGap) { + setFirstImageItemSize(1, 1); + } + return this; + } + + /** + * @param firstImageItem 设置之前选择的第一个item,用于指定默认剪裁模式,如果当前item是图片, + * 则强制所有图片剪裁模式为当前图片比例,如果当前item是视频, + * 则强制只能选择视频 + */ + public CropPickerBuilder setFirstImageItem(ImageItem firstImageItem) { + if (firstImageItem != null) { + if (firstImageItem.isVideo() || selectConfig.hasFirstImageItem()) { + return this; + } + if ((firstImageItem.width > 0 && firstImageItem.height > 0)) { + selectConfig.setFirstImageItem(firstImageItem); + } + } + return this; + } + //--------------- 以上是小红书剪裁特有属性 ------------------------------------- + + + /** + * @param selectConfig 选择配置项 + */ + public CropPickerBuilder withSelectConfig(CropSelectConfig selectConfig) { + this.selectConfig = selectConfig; + return this; + } + + + /** + * 页面直接调用剪裁器 + * + * @param activity 调用者 + * @param listener 图片视频选择回调 + */ + public void pick(Activity activity, final OnImagePickCompleteListener listener) { + checkVideoAndImage(); + if (selectConfig.getMimeTypes() == null || selectConfig.getMimeTypes().size() == 0) { + PickerErrorExecutor.executeError(listener, PickerError.MIMETYPES_EMPTY.getCode()); + presenter.tip(activity, activity.getString(R.string.picker_str_tip_mimeTypes_empty)); + return; + } + MultiImageCropActivity.intent(activity, presenter, selectConfig, listener); + } + + + /** + * fragment构建 + * + * @param imageListener 图片视频选择回调 + */ + public MultiImageCropFragment pickWithFragment(OnImagePickCompleteListener imageListener) { + checkVideoAndImage(); + MultiImageCropFragment mFragment = new MultiImageCropFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(MultiImageCropActivity.INTENT_KEY_DATA_PRESENTER, presenter); + bundle.putSerializable(MultiImageCropActivity.INTENT_KEY_SELECT_CONFIG, selectConfig); + mFragment.setArguments(bundle); + mFragment.setOnImagePickCompleteListener(imageListener); + return mFragment; + } + + /** + * 检测文件加载类型中是否全是图片或视频 + */ + private void checkVideoAndImage() { + selectConfig.setSinglePickImageOrVideoType(true); + if (selectConfig == null) { + return; + } + selectConfig.setShowVideo(false); + selectConfig.setShowImage(false); + for (MimeType mimeType : selectConfig.getMimeTypes()) { + if (MimeType.ofVideo().contains(mimeType)) { + selectConfig.setShowVideo(true); + } + if (MimeType.ofImage().contains(mimeType)) { + selectConfig.setShowImage(true); + } + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java new file mode 100644 index 0000000..5544da8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/builder/MultiPickerBuilder.java @@ -0,0 +1,426 @@ +package com.remax.visualnovel.widget.imagepicker.builder; + +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_PRESENTER; +import static com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity.INTENT_KEY_SELECT_CONFIG; + +import android.app.Activity; +import android.os.Bundle; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerActivity; +import com.remax.visualnovel.widget.imagepicker.activity.multi.MultiImagePickerFragment; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.SelectMode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.MultiSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.PickerErrorExecutor; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + + +/** + * Description: 多选选择器构造类 + *

+ * Author: peixing.yang + * Date: 2018/9/19 16:56 + */ +public class MultiPickerBuilder { + private MultiSelectConfig selectConfig; + private IPickerPresenter presenter; + + public MultiPickerBuilder(IPickerPresenter presenter) { + this.presenter = presenter; + this.selectConfig = new MultiSelectConfig(); + } + + /** + * @param isAutoComplete 设置单选模式下是否点击item就自动回调 + */ + public MultiPickerBuilder setSinglePickWithAutoComplete(boolean isAutoComplete) { + selectConfig.setSinglePickAutoComplete(isAutoComplete); + return this; + } + + /** + * @param selectLimit 设置最大数量限制 + */ + public MultiPickerBuilder setMaxCount(int selectLimit) { + selectConfig.setMaxCount(selectLimit); + return this; + } + + /** + * @param selectMode 设置选择模式 + * {@link SelectMode} + */ + public MultiPickerBuilder setSelectMode(int selectMode) { + selectConfig.setSelectMode(selectMode); + return this; + } + + /** + * @param duration 设置视频可选择的最大时长 + */ + public MultiPickerBuilder setMaxVideoDuration(long duration) { + this.selectConfig.setMaxVideoDuration(duration); + return this; + } + + /** + * @param duration 设置视频可选择的最小时长 + */ + public MultiPickerBuilder setMinVideoDuration(long duration) { + this.selectConfig.setMinVideoDuration(duration); + return this; + } + + /** + * 设置文件加载类型 + * + * @param mimeTypes 文件类型数组 + */ + public MultiPickerBuilder mimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return mimeTypes(mimeTypeSet); + } + + /** + * 设置文件加载类型 + * + * @param mimeTypes 文件类型集合 + */ + public MultiPickerBuilder filterMimeTypes(Set mimeTypes) { + if (mimeTypes != null && selectConfig != null && selectConfig.getMimeTypes() != null) { + selectConfig.getMimeTypes().removeAll(mimeTypes); + } + return this; + } + + /** + * 设置需要过滤掉的文件加载类型 + * + * @param mimeTypes 需要过滤的文件类型数组 + */ + public MultiPickerBuilder filterMimeTypes(MimeType... mimeTypes) { + if (mimeTypes == null || mimeTypes.length == 0) { + return this; + } + Set mimeTypeSet = new HashSet<>(Arrays.asList(mimeTypes)); + return filterMimeTypes(mimeTypeSet); + } + + /** + * 设置需要加载的文件类型 + * + * @param mimeTypes 需要过滤的文件类型集合 + */ + public MultiPickerBuilder mimeTypes(Set mimeTypes) { + if (mimeTypes == null || mimeTypes.size() == 0) { + return this; + } + selectConfig.setMimeTypes(mimeTypes); + return this; + } + + /** + * @param columnCount 设置列数 + */ + public MultiPickerBuilder setColumnCount(int columnCount) { + selectConfig.setColumnCount(columnCount); + return this; + } + + /** + * @param showCamera 显示拍照item + */ + public MultiPickerBuilder showCamera(boolean showCamera) { + selectConfig.setShowCamera(showCamera); + return this; + } + + /** + * 只在全部媒体相册里展示拍照 + */ + public MultiPickerBuilder showCameraOnlyInAllMediaSet(boolean showCamera) { + selectConfig.setShowCameraInAllMedia(showCamera); + return this; + } + + /** + * @param isSinglePickImageOrVideoType 是否只能选择视频或图片 + */ + public MultiPickerBuilder setSinglePickImageOrVideoType(boolean isSinglePickImageOrVideoType) { + selectConfig.setSinglePickImageOrVideoType(isSinglePickImageOrVideoType); + return this; + } + + + /** + * @param isVideoSinglePick 视频是否单选 + */ + public MultiPickerBuilder setVideoSinglePick(boolean isVideoSinglePick) { + selectConfig.setVideoSinglePick(isVideoSinglePick); + return this; + } + + + //—————————————————————— 以下为微信选择器特有的属性 —————————————————————— + + /** + * @param isPreview 视频是否支持预览 + */ + public MultiPickerBuilder setPreviewVideo(boolean isPreview) { + selectConfig.setCanPreviewVideo(isPreview); + return this; + } + + /** + * @param isPreview 是否开启预览 + */ + public MultiPickerBuilder setPreview(boolean isPreview) { + selectConfig.setPreview(isPreview); + return this; + } + + /** + * @param isOriginal 设置是否支持原图选项 + */ + public MultiPickerBuilder setOriginal(boolean isOriginal) { + selectConfig.setShowOriginalCheckBox(isOriginal); + return this; + } + + /** + * @param isOriginal 设置原图选项默认值,true则代表默认打开原图,false代表不打开 + */ + public MultiPickerBuilder setDefaultOriginal(boolean isOriginal) { + selectConfig.setDefaultOriginal(isOriginal); + return this; + } + + /** + * @param imageList 设置屏蔽项,默认打开选择器不可选择屏蔽列表的媒体文件 + * @param String or ImageItem + */ + public MultiPickerBuilder setShieldList(ArrayList imageList) { + if (imageList == null || imageList.size() == 0) { + return this; + } + selectConfig.setShieldImageList(transitArray(imageList)); + return this; + } + + /** + * @param imageList 设置上一次选择的媒体文件,默认还原上一次选择,可取消 + * @param String or ImageItem + */ + public MultiPickerBuilder setLastImageList(ArrayList imageList) { + if (imageList == null || imageList.size() == 0) { + return this; + } + selectConfig.setLastImageList(transitArray(imageList)); + return this; + } + + + //—————————————————————— 以下为单图剪裁的属性 —————————————————————— + + /** + * 设置剪裁最小间距,默认充满 + * + * @param margin 间距 + */ + public MultiPickerBuilder cropRectMinMargin(int margin) { + selectConfig.setCropRectMargin(margin); + return this; + } + + /** + * 设置剪裁头像框显示的边距 + * + * @param cropTaskFrame 间距 + */ + public MultiPickerBuilder setCropTaskFrame(int cropTaskFrame) { + selectConfig.setCropTaskFrame(cropTaskFrame); + return this; + } + + /** + * 设置剪裁模式, + *

+ * MultiSelectConfig.STYLE_FILL:充满模式 + * MultiSelectConfig.STYLE_GAP:留白模式 + * + * @param style MultiSelectConfig.STYLE_FILL or MultiSelectConfig.STYLE_GAP + */ + public MultiPickerBuilder cropStyle(int style) { + selectConfig.setCropStyle(style); + return this; + } + + /** + * 设置留白剪裁模式下背景色,如果设置成透明色,则默认生成png图片 + * + * @param color 背景色 + */ + public MultiPickerBuilder cropGapBackgroundColor(int color) { + selectConfig.setCropGapBackgroundColor(color); + return this; + } + + /** + * 设置单张图片剪裁比例 + * + * @param x 剪裁比例x + * @param y 剪裁比例y + */ + public MultiPickerBuilder setCropRatio(int x, int y) { + selectConfig.setCropRatio(x, y); + return this; + } + + /** + * 开启圆形剪裁 + */ + public MultiPickerBuilder cropAsCircle() { + selectConfig.setCircle(true); + return this; + } + + /** + * 剪裁完成的图片是否保存在DCIM目录下 + * + * @param isSaveInDCIM true:存储在系统目录DCIM下 false:存储在 data/包名/files/imagePicker/ 目录下 + */ + public MultiPickerBuilder cropSaveInDCIM(boolean isSaveInDCIM) { + selectConfig.saveInDCIM(isSaveInDCIM); + return this; + } + + /** + * 单图剪裁页面,剪裁框是否在最上层 + * + * @param singleCropCutNeedTop 剪裁框是否在activity最顶层(会盖住所有的view) + */ + public MultiPickerBuilder setSingleCropCutNeedTop(boolean singleCropCutNeedTop) { + selectConfig.setSingleCropCutNeedTop(singleCropCutNeedTop); + return this; + } + + //—————————————————————— 以上为单图剪裁的属性 —————————————————————— + + /** + * @param config 选择配置 + */ + public MultiPickerBuilder withMultiSelectConfig(MultiSelectConfig config) { + this.selectConfig = config; + return this; + } + + /** + * fragment模式调用 + * + * @param completeListener 选择回调 + * @return MultiImagePickerFragment + */ + public MultiImagePickerFragment pickWithFragment(OnImagePickCompleteListener completeListener) { + checkVideoAndImage(); + MultiImagePickerFragment mFragment = new MultiImagePickerFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(INTENT_KEY_SELECT_CONFIG, selectConfig); + bundle.putSerializable(INTENT_KEY_PRESENTER, presenter); + mFragment.setArguments(bundle); + mFragment.setOnImagePickCompleteListener(completeListener); + return mFragment; + } + + /** + * 直接开启相册选择 + * + * @param context 页面调用者 + * @param listener 选择器选择回调 + */ + public void pick(Activity context, final OnImagePickCompleteListener listener) { + checkVideoAndImage(); + if (selectConfig.getMimeTypes() == null || selectConfig.getMimeTypes().size() == 0) { + PickerErrorExecutor.executeError(listener, PickerError.MIMETYPES_EMPTY.getCode()); + presenter.tip(context, context.getString(R.string.picker_str_tip_mimeTypes_empty)); + return; + } + MultiImagePickerActivity.intent(context, selectConfig, presenter, listener); + } + + /** + * 调用单图剪裁 + * + * @param context 页面调用者 + * @param listener 选择器剪裁回调,只支持一张图片 + */ + public void crop(Activity context, OnImagePickCompleteListener listener) { + setMaxCount(1); + filterMimeTypes(MimeType.ofVideo()); + setSinglePickImageOrVideoType(false); + setSinglePickWithAutoComplete(true); + setVideoSinglePick(false); + setShieldList(null); + setLastImageList(null); + setPreview(false); + selectConfig.setSelectMode(SelectMode.MODE_CROP); + if (selectConfig.isCircle()) { + selectConfig.setCropRatio(1, 1); + } + if (selectConfig.getMimeTypes() == null || selectConfig.getMimeTypes().size() == 0) { + PickerErrorExecutor.executeError(listener, PickerError.MIMETYPES_EMPTY.getCode()); + presenter.tip(context, context.getString(R.string.picker_str_tip_mimeTypes_empty)); + return; + } + MultiImagePickerActivity.intent(context, selectConfig, presenter, listener); + } + + /** + * 检测文件加载类型中是否全是图片或视频 + */ + private void checkVideoAndImage() { + if (selectConfig == null) { + return; + } + selectConfig.setShowVideo(false); + selectConfig.setShowImage(false); + for (MimeType mimeType : selectConfig.getMimeTypes()) { + if (MimeType.ofVideo().contains(mimeType)) { + selectConfig.setShowVideo(true); + } + if (MimeType.ofImage().contains(mimeType)) { + selectConfig.setShowImage(true); + } + } + } + + /** + * 数据类型转化 + */ + private ArrayList transitArray(ArrayList imageList) { + ArrayList items = new ArrayList<>(); + for (T t : imageList) { + if (t instanceof String) { + ImageItem imageItem = new ImageItem(); + imageItem.path = (String) t; + items.add(imageItem); + } else if (t instanceof ImageItem) { + items.add((ImageItem) t); + } else { + throw new RuntimeException("ImageList item must be instanceof String or ImageItem"); + } + } + return items; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java new file mode 100644 index 0000000..b7146d8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ICameraExecutor.java @@ -0,0 +1,15 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import androidx.annotation.Nullable; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + + +public interface ICameraExecutor { + + void takePhoto(); + + void takeVideo(); + + void onTakePhotoResult(@Nullable ImageItem imageItem); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java new file mode 100644 index 0000000..435a409 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/IReloadExecutor.java @@ -0,0 +1,16 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +import java.util.List; + +public interface IReloadExecutor { + + /** + * 根据当前选择列表,重新刷新选择器选择状态 + * + * @param selectedList 当前选中列表 + */ + void reloadPickerWithList(List selectedList); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java new file mode 100644 index 0000000..4a00b58 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsDataSource.java @@ -0,0 +1,301 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATA; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATE_MODIFIED; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DURATION; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.HEIGHT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MIME_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.WIDTH; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants._ID; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Set; + + +/** + * Description: 媒体数据 + *

+ * Author: peixing.yang + * Date: 2019/4/11 + */ +public class MediaItemsDataSource implements LoaderManager.LoaderCallbacks { + private static final int LOADER_ID = 2; + private WeakReference mContext; + private LoaderManager mLoaderManager; + private MediaItemProvider mediaItemProvider; + private int preloadSize = 40; + private Set mimeTypeSet = MimeType.ofAll(); + + public MediaItemsDataSource setMimeTypeSet(BaseSelectConfig config) { + mimeTypeSet = config.getMimeTypes(); + return this; + } + + public MediaItemsDataSource setMimeTypeSet(Set mimeTypeSet) { + this.mimeTypeSet = mimeTypeSet; + return this; + } + + public MediaItemsDataSource preloadSize(int preloadSize) { + this.preloadSize = preloadSize; + return this; + } + + public void loadMediaItems(MediaItemProvider mediaItemProvider) { + this.mediaItemProvider = mediaItemProvider; + mLoaderManager.initLoader(LOADER_ID, null, this); + } + + public static MediaItemsDataSource create(FragmentActivity activity, ImageSet set) { + return new MediaItemsDataSource(activity, set); + } + + private ImageSet set; + + private MediaItemsDataSource(FragmentActivity activity, ImageSet set) { + this.set = set; + mContext = new WeakReference<>(activity); + mLoaderManager = LoaderManager.getInstance(mContext.get()); + } + + @NonNull + @Override + public Loader onCreateLoader(int id, Bundle args) { + Context context = mContext.get(); + if (context == null) { + return null; + } + return MediaItemsLoader.newInstance(context, set, mimeTypeSet); + } + + private Cursor cursor; + private Thread thread; + + @Override + public void onLoadFinished(@NonNull Loader loader, final Cursor cursor) { + final FragmentActivity context = mContext.get(); + if (context == null | cursor == null || cursor.isClosed()) { + return; + } + this.cursor = cursor; + if (thread != null && thread.isAlive()) { + return; + } + thread = new Thread(runnable); + thread.start(); + } + + + private Runnable runnable = new Runnable() { + @Override + public void run() { + final FragmentActivity context = mContext.get(); + final ArrayList imageItems = new ArrayList<>(); + ArrayList allVideoItems = new ArrayList<>(); + if (!context.isDestroyed() && !cursor.isClosed() && cursor.moveToFirst()) { + try { + + do { + ImageItem item = new ImageItem(); + item.id = getLong(cursor, _ID); + item.mimeType = getString(cursor, MIME_TYPE); + item.displayName = getString(cursor, DISPLAY_NAME); + try { + item.path = getString(cursor, DATA); + } catch (Exception ignored) { + + } + + Uri urlPath = item.getUri(); + if (urlPath != null) { + item.setUriPath(urlPath.toString()); + } + + if (item.path == null || item.path.length() == 0) { + item.path = urlPath.toString(); + } + + item.width = getInt(cursor, WIDTH); + item.height = getInt(cursor, HEIGHT); + item.setVideo(MimeType.isVideo(item.mimeType)); + item.time = getLong(cursor, DATE_MODIFIED); + item.timeFormat = PDateUtil.getStrTime(context, item.time); + + + //没有查询到路径 + if (item.path == null || item.path.length() == 0) { + continue; + } + + //视频 + if (item.isVideo()) { + item.duration = getLong(cursor, DURATION); + if (item.duration == 0) { + continue; + } + item.durationFormat = PDateUtil.getVideoDuration(item.duration); + + //如果当前加载的是全部文件,需要拼凑一个全部视频的虚拟文件夹 + if (set.isAllMedia()) { + allVideoItems.add(item); + } + } + //图片 + else { + //如果媒体信息中不包含图片的宽高,则手动获取文件宽高 + /*if (item.width == 0 || item.height == 0) { + if (!item.isUriPath()) {//此方法读取不到文件了 + int[] size = PBitmapUtils.getImageWidthHeight(item.path); + item.width = size[0]; + item.height = size[1]; + } + }*/ + } + //添加到文件列表中 + imageItems.add(item); + //回调预加载数据源 + if (preloadProvider != null && imageItems.size() == preloadSize) { + notifyPreloadItem(context, imageItems); + } + } while (!context.isDestroyed() && !cursor.isClosed() && cursor.moveToNext()); + } catch (Exception e) { + + } + } + //手动生成一个虚拟的全部视频文件夹 + ImageSet allVideoSet = null; + if (allVideoItems.size() > 0) { + allVideoSet = new ImageSet(); + allVideoSet.id = ImageSet.ID_ALL_VIDEO; + allVideoSet.coverPath = allVideoItems.get(0).path; + allVideoSet.cover = allVideoItems.get(0); + allVideoSet.count = allVideoItems.size(); + allVideoSet.imageItems = allVideoItems; + allVideoSet.name = context.getString(R.string.picker_str_folder_item_video); + } + //回调所有数据 + notifyMediaItem(context, imageItems, allVideoSet); + } + }; + + /** + * 回调预加载的媒体文件,主线程 + * + * @param context FragmentActivity + * @param imageItems 预加载列表 + */ + private void notifyPreloadItem(final FragmentActivity context, final ArrayList imageItems) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + if (context.isDestroyed()) { + return; + } + preloadProvider.providerMediaItems(imageItems); + preloadProvider = null; + } + }); + } + + /** + * 回调所有数据 + * + * @param context FragmentActivity + * @param imageItems 所有文件 + * @param allVideoSet 当加载所有媒体库文件时,默认会生成一个全部视频的文件夹,是本地虚拟的文件夹 + */ + private void notifyMediaItem(final FragmentActivity context, final ArrayList imageItems, + final ImageSet allVideoSet) { + context.runOnUiThread(new Runnable() { + @Override + public void run() { + if (context.isDestroyed()) { + return; + } + if (mediaItemProvider != null) { + mediaItemProvider.providerMediaItems(imageItems, allVideoSet); + } + + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(LOADER_ID); + } + } + }); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + + } + + public interface MediaItemProvider { + void providerMediaItems(ArrayList imageItems, ImageSet allVideoSet); + } + + private MediaItemPreloadProvider preloadProvider; + + public void setPreloadProvider(MediaItemPreloadProvider preloadProvider) { + this.preloadProvider = preloadProvider; + } + + public interface MediaItemPreloadProvider { + void providerMediaItems(ArrayList imageItems); + } + + private long getLong(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getLong(index); + } else { + return 0; + } + } + + private int getInt(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getInt(index); + } else { + return 0; + } + } + + private String getString(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getString(index); + } else { + return ""; + } + } + + private int hasColumn(Cursor data, String id) { + if (data.isClosed()) { + return -1; + } + try { + return data.getColumnIndexOrThrow(id); + } catch (Exception e) { + return -1; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java new file mode 100644 index 0000000..e998242 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaItemsLoader.java @@ -0,0 +1,84 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.content.Context; +import android.net.Uri; +import android.provider.MediaStore; + +import androidx.loader.content.CursorLoader; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; + +import java.util.ArrayList; +import java.util.Set; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATA; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DATE_MODIFIED; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.DURATION; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.HEIGHT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_IMAGE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_VIDEO; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MIME_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.SIZE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.WIDTH; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants._ID; + + +public class MediaItemsLoader extends CursorLoader { + private static final Uri QUERY_URI = MediaStore.Files.getContentUri("external"); + private static final String[] PROJECTION = { + _ID, + DATA, + DISPLAY_NAME, + WIDTH, + HEIGHT, + MIME_TYPE, + SIZE, + DURATION, + DATE_MODIFIED}; + + private static final String ORDER_BY = MediaStore.Files.FileColumns.DATE_MODIFIED + " DESC"; + + private MediaItemsLoader(Context context, String selection, String[] selectionArgs) { + super(context, QUERY_URI, PROJECTION, selection, selectionArgs, ORDER_BY); + } + + static CursorLoader newInstance(Context context, ImageSet album, Set mimeTypeSet) { + String[] selectionsArgs; + String albumSelections = ""; + String mimeSelections = ""; + int index = 0; + ArrayList arrayList = MimeType.getMimeTypeList(mimeTypeSet); + if (album.isAllMedia() || album.isAllVideo()) { + selectionsArgs = new String[arrayList.size()]; + } else { + selectionsArgs = new String[arrayList.size() + 1]; + selectionsArgs[0] = album.id; + index = 1; + albumSelections = " bucket_id=? AND "; + } + + for (String mimeType : arrayList) { + selectionsArgs[index] = mimeType; + mimeSelections = String.format("%s =? OR %s", MediaStore.Files.FileColumns.MIME_TYPE, mimeSelections); + index++; + } + + if (mimeSelections.endsWith(" OR ")) { + mimeSelections = mimeSelections.substring(0, mimeSelections.length() - 4); + } + + String selections = albumSelections + "(" + MEDIA_TYPE + "=" + MEDIA_TYPE_IMAGE + " OR " + + MEDIA_TYPE + "=" + MEDIA_TYPE_VIDEO + ")" + + " AND " + SIZE + ">0" + " AND (" + mimeSelections + ")"; + + return new MediaItemsLoader(context, selections, selectionsArgs); + } + + @Override + public void onContentChanged() { + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java new file mode 100644 index 0000000..3d8283d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsDataSource.java @@ -0,0 +1,149 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Set; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_ID; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_COUNT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_URI; + + +/** + * Description: 媒体文件夹数据 + *

+ * Author: peixing.yang + * Date: 2019/4/11 + */ +public class MediaSetsDataSource implements LoaderManager.LoaderCallbacks { + private static final int LOADER_ID = 1; + private WeakReference mContext; + private LoaderManager mLoaderManager; + private MediaSetProvider mediaSetProvider; + private boolean isLoadVideo; + private boolean isLoadImage; + + private Set mimeTypeSet = MimeType.ofAll(); + + public MediaSetsDataSource setMimeTypeSet(BaseSelectConfig config) { + isLoadImage = config.isShowImage(); + isLoadVideo = config.isShowVideo(); + mimeTypeSet = config.getMimeTypes(); + return this; + } + + public MediaSetsDataSource setMimeTypeSet(Set mimeTypeSet) { + this.mimeTypeSet = mimeTypeSet; + for (MimeType mimeType : mimeTypeSet) { + if (MimeType.ofVideo().contains(mimeType)) { + isLoadVideo = true; + } + if (MimeType.ofImage().contains(mimeType)) { + isLoadImage = true; + } + } + return this; + } + + public void loadMediaSets(MediaSetProvider mediaSetProvider) { + this.mediaSetProvider = mediaSetProvider; + mLoaderManager.initLoader(LOADER_ID, null, this); + } + + public static MediaSetsDataSource create(FragmentActivity activity) { + return new MediaSetsDataSource(activity); + } + + private MediaSetsDataSource(FragmentActivity activity) { + mContext = new WeakReference<>(activity); + mLoaderManager = LoaderManager.getInstance(mContext.get()); + } + + @NonNull + @Override + public Loader onCreateLoader(int id, Bundle args) { + Context context = mContext.get(); + return MediaSetsLoader.create(context, mimeTypeSet, isLoadVideo, isLoadImage); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor cursor) { + FragmentActivity context = mContext.get(); + if (context == null) { + return; + } + ArrayList imageSetList = new ArrayList<>(); + if (!context.isDestroyed() && cursor.moveToFirst() && !cursor.isClosed()) { + do { + ImageSet imageSet = new ImageSet(); + imageSet.id = getString(cursor, COLUMN_BUCKET_ID); + imageSet.name = getString(cursor, COLUMN_BUCKET_DISPLAY_NAME); + imageSet.coverPath = getString(cursor, COLUMN_URI); + imageSet.count = getInt(cursor, COLUMN_COUNT); + imageSetList.add(imageSet); + } while (!context.isDestroyed() && cursor.moveToNext() && !cursor.isClosed()); + } + + if (mediaSetProvider != null) { + mediaSetProvider.providerMediaSets(imageSetList); + } + + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(LOADER_ID); + } + + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + + } + + public interface MediaSetProvider { + void providerMediaSets(ArrayList imageSets); + } + + private int getInt(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getInt(index); + } else { + return 0; + } + } + + private String getString(Cursor data, String text) { + int index = hasColumn(data, text); + if (index != -1) { + return data.getString(index); + } else { + return ""; + } + } + + private int hasColumn(Cursor data, String id) { + if (data.isClosed()) { + return -1; + } + try { + return data.getColumnIndexOrThrow(id); + } catch (Exception e) { + return -1; + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java new file mode 100644 index 0000000..7a16b44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaSetsLoader.java @@ -0,0 +1,174 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.BUCKET_ORDER_BY; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_DISPLAY_NAME; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_BUCKET_ID; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_COUNT; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.COLUMN_URI; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_IMAGE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MEDIA_TYPE_VIDEO; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.MIME_TYPE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.QUERY_URI; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants.SIZE; +import static com.remax.visualnovel.widget.imagepicker.data.MediaStoreConstants._ID; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.LongSparseArray; + +import androidx.loader.content.CursorLoader; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +; + + +public class MediaSetsLoader extends CursorLoader { + private boolean isLoadVideo; + private boolean isLoadImage; + private static final String[] COLUMNS = { + _ID, + COLUMN_BUCKET_ID, + COLUMN_BUCKET_DISPLAY_NAME, + COLUMN_URI, + COLUMN_COUNT}; + private static final String[] PROJECTION = { + _ID, + COLUMN_BUCKET_ID, + COLUMN_BUCKET_DISPLAY_NAME, + MIME_TYPE}; + + private MediaSetsLoader(Context context, String selection, String[] selectionArgs, boolean isLoadVideo, boolean isLoadImage) { + super(context, QUERY_URI, PROJECTION, selection, selectionArgs, BUCKET_ORDER_BY); + this.isLoadVideo = isLoadVideo; + this.isLoadImage = isLoadImage; + } + + public static CursorLoader create(Context context, Set mimeTypeSet, boolean isLoadVideo, boolean isLoadImage) { + int index = 0; + String mimeSelection = ""; + ArrayList arrayList = MimeType.getMimeTypeList(mimeTypeSet); + String[] selectionArgs = new String[arrayList.size()]; + for (String mimeType : arrayList) { + selectionArgs[index] = mimeType; + mimeSelection = String.format("%s =? OR %s", MIME_TYPE, mimeSelection); + index++; + } + + if (mimeSelection.endsWith(" OR ")) { + mimeSelection = mimeSelection.substring(0, mimeSelection.length() - 4); + } + String selection = "(" + MEDIA_TYPE + "=" + MEDIA_TYPE_VIDEO + " OR " + MEDIA_TYPE + "=" + MEDIA_TYPE_IMAGE + ")" + + " AND " + + SIZE + ">0" + + " AND (" + + mimeSelection + ")"; + return new MediaSetsLoader(context, selection, selectionArgs, isLoadVideo, isLoadImage); + } + + @Override + public Cursor loadInBackground() { + Cursor albums = super.loadInBackground(); + MatrixCursor allAlbum = new MatrixCursor(COLUMNS); + int totalCount = 0; + Uri allAlbumCoverUri = null; + LongSparseArray countMap = new LongSparseArray<>(); + if (albums != null) { + while (albums.moveToNext()) { + int columnIndex = albums.getColumnIndex(COLUMN_BUCKET_ID); + if (columnIndex >= 0) { + long bucketId = albums.getLong(columnIndex); + Long count = countMap.get(bucketId); + count = count == null ? 1L : (count + 1); + countMap.put(bucketId, count); + } + } + } + MatrixCursor newAlbums = new MatrixCursor(COLUMNS); + if (albums != null) { + if (albums.moveToFirst()) { + allAlbumCoverUri = getUri(albums); + Set done = new HashSet<>(); + do { + int columnIndex = albums.getColumnIndex(COLUMN_BUCKET_ID); + if (columnIndex < 0) { + continue; + } + long bucketId = albums.getLong(columnIndex); + if (done.contains(bucketId)) { + continue; + } + int columnIndexId = albums.getColumnIndex(_ID); + int columnIndexDisplayName = albums.getColumnIndex(COLUMN_BUCKET_DISPLAY_NAME); + if (columnIndexId < 0 || columnIndexDisplayName < 0) { + continue; + } + long fileId = albums.getLong(columnIndexId); + String bucketDisplayName = albums.getString(columnIndexDisplayName); + Uri uri = getUri(albums); + if (uri != null) { + long count = countMap.get(bucketId); + newAlbums.addRow(new String[]{ + Long.toString(fileId), + Long.toString(bucketId), + bucketDisplayName, + uri.toString(), + String.valueOf(count)}); + done.add(bucketId); + totalCount += (int) count; + } + } while (albums.moveToNext()); + } + } + + String name = ""; + if (isLoadImage && isLoadVideo) { + name = getContext().getString(R.string.picker_str_folder_item_all); + } else if (isLoadImage) { + name = getContext().getString(R.string.picker_str_folder_item_image); + } else if (isLoadVideo) { + name = getContext().getString(R.string.picker_str_folder_item_video); + } + + allAlbum.addRow(new String[]{ImageSet.ID_ALL_MEDIA, ImageSet.ID_ALL_MEDIA, name, + allAlbumCoverUri == null ? null : allAlbumCoverUri.toString(), + String.valueOf(totalCount)}); + + return new MergeCursor(new Cursor[]{allAlbum, newAlbums}); + } + + private static Uri getUri(Cursor cursor) { + int idColumnIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID); + int mimeTypeColumnIndex = cursor.getColumnIndex(MIME_TYPE); + if (idColumnIndex < 0 || mimeTypeColumnIndex < 0) { + return null; + } + long id = cursor.getLong(idColumnIndex); + String mimeType = cursor.getString(mimeTypeColumnIndex); + Uri contentUri; + if (MimeType.isImage(mimeType)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if (MimeType.isVideo(mimeType)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else { + contentUri = QUERY_URI; + } + return ContentUris.withAppendedId(contentUri, id); + } + + @Override + public void onContentChanged() { + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java new file mode 100644 index 0000000..865ef16 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/MediaStoreConstants.java @@ -0,0 +1,40 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.net.Uri; +import android.provider.MediaStore; + +/** + * Time: 2019/10/29 20:38 + * Author:ypx + * Description: + */ +class MediaStoreConstants { + static final String MIME_TYPE = MediaStore.MediaColumns.MIME_TYPE; + static final String MEDIA_TYPE = MediaStore.Files.FileColumns.MEDIA_TYPE; + static final String DISPLAY_NAME = MediaStore.Files.FileColumns.DISPLAY_NAME; + static final int MEDIA_TYPE_VIDEO = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO; + static final int MEDIA_TYPE_IMAGE = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE; + static final String WIDTH = MediaStore.Files.FileColumns.WIDTH; + static final String HEIGHT = MediaStore.Files.FileColumns.HEIGHT; + static final String DATE_MODIFIED = MediaStore.Files.FileColumns.DATE_MODIFIED; + static final String DURATION = MediaStore.MediaColumns.DURATION; + static final String SIZE = MediaStore.MediaColumns.SIZE; + static final String _ID = MediaStore.Files.FileColumns._ID; + static final String COLUMN_BUCKET_ID = "bucket_id"; + static final String COLUMN_BUCKET_DISPLAY_NAME = "bucket_display_name"; + static final String COLUMN_URI = "uri"; + static final String COLUMN_COUNT = "count"; + static final String BUCKET_ORDER_BY = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; + /** + * android 10 已废弃此常量 + */ + static final String DATA = MediaStore.MediaColumns.DATA; + static final Uri QUERY_URI = MediaStore.Files.getContentUri("external"); + + /** + * @return 是否是Android10之前版本 + */ + static boolean isBeforeAndroidQ() { + return android.os.Build.VERSION.SDK_INT < 29; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java new file mode 100644 index 0000000..930eacf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener.java @@ -0,0 +1,18 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * Description: 图片选择器回调 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public interface OnImagePickCompleteListener extends Serializable { + void onImagePickComplete(ArrayList items); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java new file mode 100644 index 0000000..7c8c7ba --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnImagePickCompleteListener2.java @@ -0,0 +1,14 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +/** + * Description: 图片选择器回调 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public interface OnImagePickCompleteListener2 extends OnImagePickCompleteListener { + void onPickFailed(PickerError error); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java new file mode 100644 index 0000000..f18c539 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnPickerCompleteListener.java @@ -0,0 +1,41 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +import java.util.ArrayList; + +/** + * Time: 2019/10/27 22:02 + * Author:ypx + * Description: 类型回调类,调用者可自己定制回调的返回类型 + */ +public abstract class OnPickerCompleteListener implements OnImagePickCompleteListener2 { + + /** + * 默认回调出来的是 ArrayList 类型,调用者自己实现类型间转化 + * + * @param items 选择器回调 + * @return 用户自己类型 + */ + public abstract T onTransit(ArrayList items); + + /** + * 选择器完成回调 + * + * @param t 回调类型 + */ + public abstract void onPickComplete(T t); + + @Override + public void onPickFailed(PickerError error) { + + } + + @Override + public void onImagePickComplete(ArrayList items) { + onPickComplete(onTransit(items)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java new file mode 100644 index 0000000..1a0aba9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringCompleteListener.java @@ -0,0 +1,36 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +import java.util.ArrayList; + +/** + * Time: 2019/10/27 21:26 + * Author:ypx + * Description: OnPickerCompleteListener 子类,实现了String 类型回调 + */ +public abstract class OnStringCompleteListener extends OnPickerCompleteListener { + + public abstract void onPickComplete(String path); + + @Override + public String onTransit(ArrayList items) { + if (items.size() > 0 && items.get(0) != null) { + return items.get(0).path; + } + return null; + } + + @Override + public void onPickFailed(PickerError error) { + + } + + @Override + public void onImagePickComplete(ArrayList items) { + onPickComplete(onTransit(items)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java new file mode 100644 index 0000000..9254490 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/OnStringListCompleteListener.java @@ -0,0 +1,37 @@ +package com.remax.visualnovel.widget.imagepicker.data; + + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; + +import java.util.ArrayList; + +/** + * Time: 2019/10/27 21:26 + * Author:ypx + * Description:OnPickerCompleteListener子类,实现了ArrayList 回调 + */ +public abstract class OnStringListCompleteListener extends OnPickerCompleteListener> { + + public abstract void onPickComplete(ArrayList list); + + @Override + public void onPickFailed(PickerError error) { + + } + + @Override + public void onImagePickComplete(ArrayList items) { + onPickComplete(onTransit(items)); + } + + @Override + public ArrayList onTransit(ArrayList items) { + ArrayList list = new ArrayList<>(); + for (ImageItem item : items) { + list.add(item.path); + } + return list; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java new file mode 100644 index 0000000..1585aa4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/PickerActivityCallBack.java @@ -0,0 +1,42 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +import android.content.Intent; + + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; + +import java.util.ArrayList; + +/** + * Time: 2019/11/6 17:35 + * Author:ypx + * Description:选择器activityResult处理类 + */ +public class PickerActivityCallBack implements PLauncher.Callback { + private OnImagePickCompleteListener listener; + + public static PickerActivityCallBack create(OnImagePickCompleteListener listener) { + return new PickerActivityCallBack(listener); + } + + private PickerActivityCallBack(OnImagePickCompleteListener listener) { + this.listener = listener; + } + + @Override + public void onActivityResult(int resultCode, Intent data) { + if (listener != null + && resultCode == ImagePicker.REQ_PICKER_RESULT_CODE + && data.hasExtra(ImagePicker.INTENT_KEY_PICKER_RESULT)) { + ArrayList list = (ArrayList) data.getSerializableExtra(ImagePicker.INTENT_KEY_PICKER_RESULT); + listener.onImagePickComplete(list); + } else if (listener instanceof OnImagePickCompleteListener2) { + if (resultCode == 0) { + resultCode = PickerError.CANCEL.getCode(); + } + ((OnImagePickCompleteListener2) listener).onPickFailed(PickerError.valueOf(resultCode)); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java new file mode 100644 index 0000000..edc172f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/data/ProgressSceneEnum.java @@ -0,0 +1,6 @@ +package com.remax.visualnovel.widget.imagepicker.data; + +public enum ProgressSceneEnum { + loadMediaItem, + crop +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java new file mode 100644 index 0000000..47d2c1b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CameraCompat.java @@ -0,0 +1,144 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.bean.UriPathInfo; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.helper.launcher.PLauncher; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PDateUtil; +import com.remax.visualnovel.widget.imagepicker.utils.PPermissionUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PSingleMediaScanner; +import com.remax.visualnovel.widget.imagepicker.utils.PickerFileProvider; + +import java.io.File; +import java.util.ArrayList; + +public class CameraCompat { + + + /** + * 兼容安卓10拍照.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity 调用拍照的页面 + * @param imageName 图片名称 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 拍照回调 + */ + public static void takePhoto(final Activity activity, + final String imageName, + final boolean isCopyInDCIM, + final OnImagePickCompleteListener listener) { + final String path = PBitmapUtils.getPickerFileDirectory(activity).getAbsolutePath() + + File.separator + imageName + ".jpg"; + if (!PPermissionUtils.hasCameraPermissions(activity) || listener == null) { + return; + } + final Uri imageUri = PickerFileProvider.getUriForFile(activity, new File(path)); + PLauncher.init(activity).startActivityForResult(getTakePhotoIntent(activity, imageUri), (resultCode, data) -> { + if (resultCode != Activity.RESULT_OK || path.trim().isEmpty()) { + PickerErrorExecutor.executeError(listener, PickerError.TAKE_PHOTO_FAILED.getCode()); + return; + } + UriPathInfo uriPathInfo; + if (isCopyInDCIM) { + uriPathInfo = PBitmapUtils.copyFileToDCIM(activity, path, imageName, MimeType.JPEG); + PSingleMediaScanner.refresh(activity, uriPathInfo.absolutePath, null); + } else { + uriPathInfo = new UriPathInfo(imageUri, path); + } + + ImageItem item = new ImageItem(); + item.path = uriPathInfo.absolutePath; + item.mimeType = MimeType.JPEG.toString(); + item.setUriPath(uriPathInfo.uri.toString()); + item.time = System.currentTimeMillis(); + int[] size = PBitmapUtils.getImageWidthHeight(path); + item.width = size[0]; + item.height = size[1]; + item.mimeType = MimeType.JPEG.toString(); + ArrayList list = new ArrayList<>(); + list.add(item); + listener.onImagePickComplete(list); + }); + } + + /** + * 兼容安卓10拍摄视频.因为安卓Q禁止直接写入文件到系统DCIM文件下,所以拍照入参必须是私有目录路径 + * 如果想让拍摄的照片写入外部存储中,则需要copy一份文件到DCIM目录中并刷新媒体库 + * + * @param activity activity + * @param videoName 视频保存路径 + * @param maxDuration 视频最大时长 + * @param isCopyInDCIM 是否copy到DCIM中 + * @param listener 视频回调 + */ + public static void takeVideo(final Activity activity, + final String videoName, + long maxDuration, + final boolean isCopyInDCIM, + final OnImagePickCompleteListener listener) { + if (!PPermissionUtils.hasCameraPermissions(activity) || listener == null) { + return; + } + final String path = PBitmapUtils.getPickerFileDirectory(activity).getAbsolutePath() + + File.separator + videoName + ".mp4"; + final Uri videoUri = PickerFileProvider.getUriForFile(activity, new File(path)); + PLauncher.init(activity).startActivityForResult(getTakeVideoIntent(activity, videoUri, maxDuration), (resultCode, data) -> { + if (resultCode != Activity.RESULT_OK || path.trim().isEmpty()) { + PickerErrorExecutor.executeError(listener, PickerError.TAKE_PHOTO_FAILED.getCode()); + return; + } + UriPathInfo uriPathInfo; + if (isCopyInDCIM) { + uriPathInfo = PBitmapUtils.copyFileToDCIM(activity, path, videoName, MimeType.MP4); + PSingleMediaScanner.refresh(activity, uriPathInfo.absolutePath, null); + } else { + uriPathInfo = new UriPathInfo(videoUri, path); + } + + ImageItem item = new ImageItem(); + item.path = uriPathInfo.absolutePath; + item.setUriPath(uriPathInfo.uri.toString()); + item.time = System.currentTimeMillis(); + item.mimeType = MimeType.MP4.toString(); + item.setVideo(true); + item.duration = PBitmapUtils.getLocalVideoDuration(path); + item.setDurationFormat(PDateUtil.getVideoDuration(item.duration)); + ArrayList list = new ArrayList<>(); + list.add(item); + listener.onImagePickComplete(list); + }); + } + + private static Intent getTakePhotoIntent(Activity activity, Uri imageUri) { + Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + + intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + return intent; + } + + private static Intent getTakeVideoIntent(Activity activity, Uri imageUri, long maxDuration) { + Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE); + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri); + if (maxDuration > 1) { + intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, maxDuration / 1000L); + } + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + return intent; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java new file mode 100644 index 0000000..88d26c6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/CropViewContainerHelper.java @@ -0,0 +1,169 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageCropMode; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Time: 2019/9/30 9:45 + * Author:ypx + * Description: 剪裁View封装 + */ +public class CropViewContainerHelper { + private WeakReference parentReference; + //存储已选择的剪裁View + private HashMap cropViewList = new HashMap<>(); + + public CropViewContainerHelper(@NonNull ViewGroup parent) { + parentReference = new WeakReference<>(parent); + } + + private ViewGroup getParent() { + if (parentReference != null && parentReference.get() != null) { + return parentReference.get(); + } + return null; + } + + public void setBackgroundColor(int color) { + if (mCropView != null) { + mCropView.setBackgroundColor(color); + } + } + + private CropImageView mCropView; + + public CropImageView loadCropView(final Context context, final ImageItem imageItem, final int mCropSize, + final IPickerPresenter presenter, final onLoadComplete loadComplete) { + final Activity activity = (Activity) context; + if (cropViewList.containsKey(imageItem) && cropViewList.get(imageItem) != null) { + mCropView = cropViewList.get(imageItem); + } else { + mCropView = new CropImageView(context); + //设置剪裁view的属性 + mCropView.setScaleType(ImageView.ScaleType.CENTER_CROP); + mCropView.enable(); // 启用图片缩放功能 + mCropView.setMaxScale(3.0f); + mCropView.setCanShowTouchLine(true); + mCropView.setShowImageRectLine(true); + if (imageItem.width == 0 || imageItem.height == 0) { + mCropView.setOnImageLoadListener(new CropImageView.onImageLoadListener() { + @Override + public void onImageLoaded(float w, float h) { + imageItem.width = (int) w; + imageItem.height = (int) h; + if (loadComplete != null) { + loadComplete.loadComplete(); + } + } + }); + } + + + DetailImageLoadHelper.displayDetailImage(true, mCropView, presenter, imageItem); + } + + if (getParent() != null) { + getParent().removeAllViews(); + if (mCropView.getParent() != null) { + ((ViewGroup) mCropView.getParent()).removeView(mCropView); + } + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(mCropSize, mCropSize); + params.gravity = Gravity.CENTER; + getParent().addView(mCropView, params); + } + return mCropView; + } + + public interface onLoadComplete { + void loadComplete(); + } + + public void addCropView(CropImageView view, ImageItem imageItem) { + if (!cropViewList.containsKey(imageItem)) { + cropViewList.put(imageItem, view); + } + } + + public void removeCropView(ImageItem imageItem) { + cropViewList.remove(imageItem); + } + + public void refreshAllState(ImageItem currentImageItem, List selectList, + ViewGroup invisibleContainer, + boolean isFitState, + ResetSizeExecutor executor) { + invisibleContainer.removeAllViews(); + invisibleContainer.setVisibility(View.VISIBLE); + for (ImageItem imageItem : selectList) { + if (imageItem == currentImageItem) { + continue; + } + CropImageView picBrowseImageView = cropViewList.get(imageItem); + if (picBrowseImageView != null) { + invisibleContainer.addView(picBrowseImageView); + if (executor != null) { + executor.resetAllCropViewSize(picBrowseImageView); + } + if (isFitState) { + imageItem.setCropMode(ImageCropMode.ImageScale_FILL); + picBrowseImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + } + cropViewList.put(imageItem, picBrowseImageView); + } + } + invisibleContainer.setVisibility(View.INVISIBLE); + } + + public ArrayList generateCropUrls(List selectList, int cropMode) { + for (ImageItem imageItem : selectList) { + CropImageView view = cropViewList.get(imageItem); + if (view == null) { + continue; + } + view.requestLayout(); + Bitmap bitmap; + if (imageItem.getCropMode() == ImageCropMode.ImageScale_GAP) { + bitmap = view.generateCropBitmapFromView(Color.WHITE); + } else { + bitmap = view.generateCropBitmap(); + } + String cropUrl = PBitmapUtils.saveBitmapToFile(view.getContext(), bitmap, + "crop_" + System.currentTimeMillis(), + Bitmap.CompressFormat.JPEG); + if (imageItem.getCropUrl() != null && imageItem.getCropUrl().length() > 0) { + new File(imageItem.getCropUrl()).delete(); + } + imageItem.setCropUrl(cropUrl); + imageItem.setCropMode(cropMode); + imageItem.setPress(false); + } + return (ArrayList) selectList; + } + + + public interface ResetSizeExecutor { + void resetAllCropViewSize(CropImageView view); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java new file mode 100644 index 0000000..8d048a9 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/DetailImageLoadHelper.java @@ -0,0 +1,24 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.widget.ImageView; + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + + +public class DetailImageLoadHelper { + + public static void displayDetailImage(boolean isCrop, final ImageView imageView, final IPickerPresenter presenter, final ImageItem imageItem) { + if (presenter != null) { + //剪裁不压缩,大图预览尺寸超过2K的图片需要压缩,不能使用ARGB-8888加载,滑动会卡顿,并且浪费内存, + // 其实最好的做法是分段加载,但是cropImageView在支持剪裁的基础上不能支持分段加载 + if (isCrop || ImagePicker.isPreviewWithHighQuality()) { + presenter.displayImage(imageView, imageItem, imageView.getWidth(), false); + } else { + presenter.displayImage(imageView, imageItem, imageView.getWidth(), imageItem.isOver2KImage()); + } + + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java new file mode 100644 index 0000000..e19ea17 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/PickerErrorExecutor.java @@ -0,0 +1,29 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.app.Activity; + +import com.remax.visualnovel.widget.imagepicker.bean.PickerError; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener; +import com.remax.visualnovel.widget.imagepicker.data.OnImagePickCompleteListener2; + + +/** + * Time: 2019/10/18 9:53 + * Author:ypx + * Description: 调用选择器失败回调 + */ +public class PickerErrorExecutor { + + public static void executeError(Activity activity, int code) { + if (activity != null) { + activity.setResult(code); + activity.finish(); + } + } + + public static void executeError(OnImagePickCompleteListener listener, int code) { + if (listener instanceof OnImagePickCompleteListener2) { + ((OnImagePickCompleteListener2) listener).onPickFailed(PickerError.valueOf(code)); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java new file mode 100644 index 0000000..2cd2f85 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/RecyclerViewTouchHelper.java @@ -0,0 +1,339 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.animation.ValueAnimator; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.widget.TouchRecyclerView; + + +/** + * Description: 滑动辅助类 + *

+ * Author: peixing.yang + * Date: 2019/2/26 + */ +public class RecyclerViewTouchHelper { + private TouchRecyclerView recyclerView; + private View topView; + private View maskView; + private boolean isScrollTopView = false; + private boolean isTopViewStick = false; + private int canScrollHeight; + private int stickHeight; + + public static RecyclerViewTouchHelper create(TouchRecyclerView recyclerView) { + return new RecyclerViewTouchHelper(recyclerView); + } + + private RecyclerViewTouchHelper(TouchRecyclerView recyclerView) { + this.recyclerView = recyclerView; + } + + public RecyclerViewTouchHelper setTopView(View topView) { + this.topView = topView; + return this; + } + + public RecyclerViewTouchHelper setMaskView(View maskView) { + this.maskView = maskView; + return this; + } + + public RecyclerViewTouchHelper setCanScrollHeight(int canScrollHeight) { + this.canScrollHeight = canScrollHeight; + return this; + } + + public RecyclerViewTouchHelper setStickHeight(int stickHeight) { + this.stickHeight = stickHeight; + return this; + } + + private void setRecyclerViewPaddingTop(int top) { + recyclerView.setPadding(recyclerView.getPaddingStart(), top, + recyclerView.getPaddingEnd(), recyclerView.getPaddingBottom()); + } + + private int lastScrollY = 0; + + public RecyclerViewTouchHelper build() { + setRecyclerViewPaddingTop(canScrollHeight + stickHeight); + recyclerView.post(new Runnable() { + @Override + public void run() { + setRecyclerViewPaddingTop(topView.getHeight()); + } + }); + recyclerView.setTouchView(topView); + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + if (isImageGridCantScroll()) { + return; + } + int scrollY = getScrollYDistance(); + if (isScrollTopView && topView.getTranslationY() != -canScrollHeight) { + if (lastScrollY == 0) { + lastScrollY = scrollY; + } + int distance = scrollY - lastScrollY; + if (distance >= canScrollHeight) { + setMaskAlpha(1); + topView.setTranslationY(-canScrollHeight); + setRecyclerViewPaddingTop(stickHeight); + } else { + if (distance <= 0) { + setMaskAlpha(0); + topView.setTranslationY(0); + } else { + float ratio = -distance * 1.00f / (-canScrollHeight * 1.00f); + setMaskAlpha(ratio); + topView.setTranslationY(-distance); + } + } + return; + } + if (isTopViewFullShow()) { + isTopViewStick = false; + setMaskAlpha(0); + } + + if (isTopViewStick) { + int translate = -scrollY - topView.getHeight(); + if (translate <= -canScrollHeight) { + topView.setTranslationY(-canScrollHeight); + setRecyclerViewPaddingTop(stickHeight); + isTopViewStick = false; + } else { + if (translate >= -20) { + translate = 0; + } + topView.setTranslationY(translate); + float ratio = topView.getTranslationY() * 1.00f / (-topView.getHeight() * 1.00f); + setMaskAlpha(ratio); + } + } + } + }); + + recyclerView.setDragScrollListener(new TouchRecyclerView.onDragScrollListener() { + @Override + public void onScrollOverTop(int distance) { + if (isImageGridCantScroll()) { + return; + } + isScrollTopView = true; + + } + + @Override + public void onScrollDown(int distance) { + if (isImageGridCantScroll()) { + return; + } + if (isRecyclerViewScrollToTop() && !isScrollTopView) { + setRecyclerViewPaddingTop(topView.getHeight()); + isTopViewStick = true; + } + } + + @Override + public void onScrollUp() { + lastScrollY = 0; + if (isImageGridCantScroll()) { + return; + } + if (isScrollTopView) { + transitTopWithAnim(!isRecyclerViewCanScrollOverScreen(), -1, true); + } else { + if (isTopViewStick && !isTopViewFullShow()) { + reset(); + } + } + isScrollTopView = false; + } + }); + return this; + } + + private boolean isRecyclerViewScrollToTop() { + return !recyclerView.canScrollVertically(-1); + } + + private boolean isRecyclerViewScrollToBottom() { + return !recyclerView.canScrollVertically(1); + } + + private boolean isRecyclerViewCanScrollOverScreen() { + if (isImageGridCantScroll()) { + return false; + } + int count = 0; + if (recyclerView.getAdapter() != null) { + count = recyclerView.getAdapter().getItemCount(); + } + int itemHeight = getItemHeight(); + if (count < getSpanCount()) { + return false; + } + int lineCount = count % getSpanCount() == 0 ? count / getSpanCount() : count / getSpanCount() + 1; + return lineCount * itemHeight + recyclerView.getPaddingBottom() > + PViewSizeUtils.getScreenHeight(recyclerView.getContext()) - stickHeight; + } + + private int spanCount = 0; + + private int getSpanCount() { + if (spanCount != 0) { + return spanCount; + } + GridLayoutManager gridLayoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + if (gridLayoutManager != null) { + spanCount = gridLayoutManager.getSpanCount(); + return spanCount; + } + return 0; + } + + /** + * 设置剪裁区域阴影 + * + * @param ratio 阴影比例 + */ + private void setMaskAlpha(float ratio) { + maskView.setVisibility(View.VISIBLE); + if (ratio <= 0) { + ratio = 0; + maskView.setVisibility(View.GONE); + } else if (ratio >= 1) { + ratio = 1; + } + maskView.setAlpha(ratio); + } + + /** + * 剪裁区域+标题栏 是否完整显示 + */ + private boolean isTopViewFullShow() { + return (topView.getTranslationY() == 0); + } + + + /** + * 选择图片recyclerView是否不可以滑动(数量少) + */ + private boolean isImageGridCantScroll() { + return !recyclerView.canScrollVertically(1) && + !recyclerView.canScrollVertically(-1); + } + + + /** + * 获取recyclerView滑动距离 + */ + private int getScrollYDistance() { + if (!(recyclerView.getLayoutManager() instanceof GridLayoutManager)) { + return 0; + } + GridLayoutManager gridLayoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + int position = gridLayoutManager.findFirstVisibleItemPosition(); + if (position < 0) { + position = 0; + } + View firstVisibleChildView = gridLayoutManager.findViewByPosition(position); + if (firstVisibleChildView == null) { + return 0; + } + + int itemHeight = firstVisibleChildView.getHeight() + PViewSizeUtils.dp(recyclerView.getContext(), 2); + return (position / getSpanCount()) * itemHeight - firstVisibleChildView.getTop(); + } + + private int getItemHeight() { + if (!(recyclerView.getLayoutManager() instanceof GridLayoutManager)) { + return 0; + } + GridLayoutManager gridLayoutManager = (GridLayoutManager) recyclerView.getLayoutManager(); + int position = gridLayoutManager.findFirstVisibleItemPosition(); + if (position < 0) { + position = 0; + } + View firstVisibleChildView = gridLayoutManager.findViewByPosition(position); + if (firstVisibleChildView == null) { + return 0; + } + return firstVisibleChildView.getHeight(); + } + + private void reset() { + final int scrollY = getScrollYDistance(); + if (scrollY == 0) { + return; + } + ValueAnimator anim = ValueAnimator.ofFloat(0.0f, 1.0f); + anim.setDuration(500); + anim.setInterpolator(new AccelerateDecelerateInterpolator()); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float ratio = (Float) animation.getAnimatedValue(); + recyclerView.scrollBy(0, (int) (scrollY * ratio)); + } + }); + anim.start(); + } + + /** + * 动画控制是否展开还是完整显示topView + * + * @param isFocusShow 是否强制完全展示 + * @param scrollToPosition 滑动到制定的位置 + */ + public void transitTopWithAnim(boolean isFocusShow, final int scrollToPosition, boolean isShowTransit) { + if (!isShowTransit) { + return; + } + if (isTopViewFullShow()) { + return; + } + final int startTop = (int) topView.getTranslationY(); + //如果滑动区域小于标题栏高度的一半,则完全展示,否则收回剪裁区域到顶部 + final int endTop = (isFocusShow || (startTop > -stickHeight / 2)) ? 0 : -canScrollHeight; + final int startPadding = recyclerView.getPaddingTop(); + final float startAlpha = maskView.getAlpha(); + ValueAnimator anim = ValueAnimator.ofFloat(0.0f, 1.0f); + anim.setDuration(300); + anim.setInterpolator(new AccelerateDecelerateInterpolator()); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float ratio = (Float) animation.getAnimatedValue(); + int dis = (int) ((endTop - startTop) * ratio + startTop); + topView.setTranslationY(dis); + float maskAlpha = endTop == 0 ? (-startAlpha) * ratio + startAlpha : (1 - startAlpha) * ratio + startAlpha; + setMaskAlpha(maskAlpha); + int padding = (int) (((endTop == 0 ? topView.getHeight() : stickHeight) - startPadding) * ratio + startPadding); + setRecyclerViewPaddingTop(padding); + if (ratio == 1.0f) { + if (scrollToPosition == 0) { + recyclerView.scrollToPosition(0); + } else if (scrollToPosition != -1) { + recyclerView.smoothScrollToPosition(scrollToPosition); + } + } + } + }); + anim.start(); + } + + public int dp(int dp) { + return PViewSizeUtils.dp(recyclerView.getContext(), dp); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java new file mode 100644 index 0000000..521a4b6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/VideoViewContainerHelper.java @@ -0,0 +1,122 @@ +package com.remax.visualnovel.widget.imagepicker.helper; + +import android.content.Context; +import android.graphics.Color; +import android.media.MediaPlayer; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.VideoView; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; + + +/** + * Time: 2019/9/30 9:45 + * Author:ypx + * Description: 视频播放 + */ +public class VideoViewContainerHelper { + private VideoView videoView; + private ImageView previewImg; + private ImageView pauseImg; + + public void loadVideoView(ViewGroup parent, ImageItem imageItem, IPickerPresenter presenter, PickerUiConfig uiConfig) { + Context context = parent.getContext(); + + if (videoView == null) { + videoView = new VideoView(context); + videoView.setBackgroundColor(Color.TRANSPARENT); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT); + params.gravity = Gravity.CENTER; + videoView.setLayoutParams(params); + + previewImg = new ImageView(context); + previewImg.setLayoutParams(params); + previewImg.setScaleType(ImageView.ScaleType.FIT_CENTER); + + pauseImg = new ImageView(context); + pauseImg.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + pauseImg.setImageDrawable(context.getResources().getDrawable(uiConfig.getVideoPauseIconID())); + FrameLayout.LayoutParams params2 = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + params2.gravity = Gravity.CENTER; + pauseImg.setLayoutParams(params2); + } + pauseImg.setVisibility(View.GONE); + parent.removeAllViews(); + parent.addView(videoView); + parent.addView(previewImg); + parent.addView(pauseImg); + previewImg.setVisibility(View.VISIBLE); + presenter.displayImage(previewImg, imageItem, 0, false); + videoView.setVideoPath(imageItem.path); + videoView.start(); + //监听视频播放完的代码 + videoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mPlayer) { + mPlayer.start(); + mPlayer.setLooping(true); + } + }); + + videoView.setOnClickListener(v -> { + if (videoView.isPlaying()) { + onPause(); + } else { + onResume(); + } + }); + + videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(MediaPlayer mp) { + mp.setOnSeekCompleteListener(new MediaPlayer.OnSeekCompleteListener() { + @Override + public void onSeekComplete(MediaPlayer mp) { + videoView.start(); + } + }); + mp.setOnInfoListener(new MediaPlayer.OnInfoListener() { + @Override + public boolean onInfo(MediaPlayer mp, int what, int extra) { + if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) { + // video 视屏播放的时候把背景设置为透明 + videoView.setBackgroundColor(Color.TRANSPARENT); + previewImg.setVisibility(View.GONE); + return true; + } + return false; + } + }); + } + }); + } + + public void onResume() { + if (videoView != null && pauseImg != null) { + videoView.start(); + videoView.seekTo(videoView.getCurrentPosition()); + pauseImg.setVisibility(View.GONE); + } + } + + public void onPause() { + if (videoView != null && pauseImg != null) { + videoView.pause(); + pauseImg.setVisibility(View.VISIBLE); + } + } + + public void onDestroy() { + if (videoView != null) { + videoView.suspend();//将VideoView所占用的资源释放掉 + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java new file mode 100644 index 0000000..0126cfb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PLauncher.java @@ -0,0 +1,106 @@ +package com.remax.visualnovel.widget.imagepicker.helper.launcher; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; + + +/** + * Activity跳转封装类,把OnActivityResult方式改为Callback方式 + *

+ * Created by XiaoFeng on 2018/9/5. + */ +public class PLauncher { + + private static final String TAG = "PLauncher"; + private Context mContext; + /** + * V4兼容包下的Fragment + */ + private PRouterV4 mRouterFragmentV4; + /** + * 标准SDK下的Fragment + */ + private PRouter mRouterFragment; + + public static PLauncher init(Fragment fragment) { + return init(fragment.getActivity()); + } + + public static PLauncher init(FragmentActivity activity) { + return new PLauncher(activity); + } + + public static PLauncher init(Activity activity) { + return new PLauncher(activity); + } + + private PLauncher(FragmentActivity activity) { + mContext = activity; + mRouterFragmentV4 = getRouterFragmentV4(activity); + } + + private PLauncher(Activity activity) { + mContext = activity; + mRouterFragment = getRouterFragment(activity); + } + + private PRouterV4 getRouterFragmentV4(FragmentActivity activity) { + PRouterV4 routerFragment = findRouterFragmentV4(activity); + if (routerFragment == null) { + routerFragment = PRouterV4.newInstance(); + FragmentManager fragmentManager = activity.getSupportFragmentManager(); + fragmentManager + .beginTransaction() + .add(routerFragment, TAG) + .commitAllowingStateLoss(); + fragmentManager.executePendingTransactions(); + } + return routerFragment; + } + + private PRouterV4 findRouterFragmentV4(FragmentActivity activity) { + return (PRouterV4) activity.getSupportFragmentManager().findFragmentByTag(TAG); + } + + private PRouter getRouterFragment(Activity activity) { + PRouter routerFragment = findRouterFragment(activity); + if (routerFragment == null) { + routerFragment = PRouter.newInstance(); + android.app.FragmentManager fragmentManager = activity.getFragmentManager(); + fragmentManager + .beginTransaction() + .add(routerFragment, TAG) + .commitAllowingStateLoss(); + fragmentManager.executePendingTransactions(); + } + return routerFragment; + } + + private PRouter findRouterFragment(Activity activity) { + return (PRouter) activity.getFragmentManager().findFragmentByTag(TAG); + } + + public void startActivityForResult(Class clazz, Callback callback) { + Intent intent = new Intent(mContext, clazz); + startActivityForResult(intent, callback); + } + + public void startActivityForResult(Intent intent, Callback callback) { + if (mRouterFragmentV4 != null) { + mRouterFragmentV4.startActivityForResult(intent, callback); + } else if (mRouterFragment != null) { + mRouterFragment.startActivityForResult(intent, callback); + } else { + throw new RuntimeException("please do init first!"); + } + } + + public interface Callback { + void onActivityResult(int resultCode, Intent data); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java new file mode 100644 index 0000000..bfc5a45 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouter.java @@ -0,0 +1,72 @@ +package com.remax.visualnovel.widget.imagepicker.helper.launcher; + +import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.os.Bundle; +import android.util.SparseArray; + + +import java.util.Random; + +import timber.log.Timber; + +/** + * 把OnActivityResult方式转换为Callback方式的空Fragment(标准SDK) + *

+ * Created by XiaoFeng on 2018/9/5. + */ +public class PRouter extends Fragment { + + private SparseArray mCallbacks = new SparseArray<>(); + private Random mCodeGenerator = new Random(); + + public PRouter() { + // Required empty public constructor + } + + public static PRouter newInstance() { + return new PRouter(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + public void startActivityForResult(Intent intent, PLauncher.Callback callback) { + try { + int requestCode = makeRequestCode(); + mCallbacks.put(requestCode, callback); + startActivityForResult(intent, requestCode); + } catch (ActivityNotFoundException ex) { + Timber.e(ex); + } + } + + /** + * 随机生成唯一的requestCode,最多尝试10次 + * + * @return + */ + private int makeRequestCode() { + int requestCode; + int tryCount = 0; + do { + requestCode = mCodeGenerator.nextInt(0x0000FFFF); + tryCount++; + } while (mCallbacks.indexOfKey(requestCode) >= 0 && tryCount < 10); + return requestCode; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + PLauncher.Callback callback = mCallbacks.get(requestCode); + mCallbacks.remove(requestCode); + if (callback != null) { + callback.onActivityResult(resultCode, data); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java new file mode 100644 index 0000000..8e47280 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/launcher/PRouterV4.java @@ -0,0 +1,66 @@ +package com.remax.visualnovel.widget.imagepicker.helper.launcher; + +import android.content.Intent; +import android.os.Bundle; +import android.util.SparseArray; + +import androidx.fragment.app.Fragment; + + +import java.util.Random; + +/** + * 把OnActivityResult方式转换为Callback方式的空Fragment(V4兼容包) + * + * Created by XiaoFeng on 2018/9/5. + */ +public class PRouterV4 extends Fragment { + + private SparseArray mCallbacks = new SparseArray<>(); + private Random mCodeGenerator = new Random(); + + public PRouterV4() { + // Required empty public constructor + } + + public static PRouterV4 newInstance() { + return new PRouterV4(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + public void startActivityForResult(Intent intent, PLauncher.Callback callback) { + int requestCode = makeRequestCode(); + mCallbacks.put(requestCode, callback); + startActivityForResult(intent, requestCode); + } + + /** + * 随机生成唯一的requestCode,最多尝试10次 + * + * @return + */ + private int makeRequestCode() { + int requestCode; + int tryCount = 0; + do { + requestCode = mCodeGenerator.nextInt(0x0000FFFF); + tryCount++; + } while (mCallbacks.indexOfKey(requestCode) >= 0 && tryCount < 10); + return requestCode; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + PLauncher.Callback callback = mCallbacks.get(requestCode); + mCallbacks.remove(requestCode); + if (callback != null) { + callback.onActivityResult(resultCode, data); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java new file mode 100644 index 0000000..ed00a1c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperAdapter.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 Paul Burke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper; + +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Interface to listen for a move or dismissal event from a {@link ItemTouchHelper.Callback}. + * + * @author Paul Burke (ipaulpro) + */ +public interface ItemTouchHelperAdapter { + + /** + * Called when an item has been dragged far enough to trigger a move. This is called every time + * an item is shifted, and not at the end of a "drop" event.
+ *
+ * Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after + * adjusting the underlying data to reflect this move. + * + * @param fromPosition The start position of the moved item. + * @param toPosition Then resolved position of the moved item. + * @return True if the item was moved to the new adapter position. + * @see RecyclerView.ViewHolder#getAdapterPosition() + */ + boolean onItemMove(int fromPosition, int toPosition); + + /** + * Called when an item has been dismissed by a swipe.
+ *
+ * Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after + * adjusting the underlying data to reflect this removal. + * + * @param position The position of the item dismissed. + * @see RecyclerView.ViewHolder#getAdapterPosition() + */ + void onItemDismiss(int position); + + /** + * 是否可以移动 + * + * @return + */ + boolean isItemViewSwipeEnabled(); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java new file mode 100644 index 0000000..6e42d61 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/ItemTouchHelperViewHolder.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 Paul Burke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper; + +import androidx.recyclerview.widget.ItemTouchHelper; + +/** + * Interface to notify an item ViewHolder of relevant callbacks from {@link + * ItemTouchHelper.Callback}. + * + * @author Paul Burke (ipaulpro) + */ +public interface ItemTouchHelperViewHolder { + + /** + * Called when the {@link ItemTouchHelper} first registers an item as being moved or swiped. + * Implementations should update the item view to indicate it's active state. + */ + void onItemSelected(); + + + /** + * Called when the {@link ItemTouchHelper} has completed the move or swipe, and the active item + * state should be cleared. + */ + void onItemClear(); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java new file mode 100644 index 0000000..a4a8a1b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/helper/recyclerviewitemhelper/SimpleItemTouchHelperCallback.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2015 Paul Burke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper; + +import android.graphics.Canvas; + +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + + + + +/** + * An implementation of {@link ItemTouchHelper.Callback} that enables basic drag & drop and + * swipe-to-dismiss. Drag events are automatically started by an item long-press.
+ *
+ * Expects the RecyclerView.Adapter to listen for {@link + * ItemTouchHelperAdapter} callbacks and the RecyclerView.ViewHolder to implement + * {@link ItemTouchHelperViewHolder}. + * + * @author Paul Burke (ipaulpro) + */ +public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback { + public static final float ALPHA_FULL = 1.0f; + private boolean moveFreely = false; + private boolean lastActive = false; + // 移动时,item 的放大系数 + private float moveScaleFactor = 1.1f; + private final ItemTouchHelperAdapter mAdapter; + private OnSelectChangedListener mOnSelectChangedListener; + + public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) { + mAdapter = adapter; + } + + public void setOnSelectChangedListener(OnSelectChangedListener mOnSelectChangedListener) { + this.mOnSelectChangedListener = mOnSelectChangedListener; + } + + public void setMoveScaleFactor(float moveScaleFactor) { + this.moveScaleFactor = moveScaleFactor; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return mAdapter.isItemViewSwipeEnabled(); + } + + public interface OnSelectChangedListener { + /** + * @param viewHolder + * @param dX + * @param dY + * @param actionState + * @param isCurrentlyActive + */ + void onSelectedChanged(RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive); + + /** + * @param viewHolder + * @param actionState + */ + void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState); + } + + @Override + public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + // Set movement flags based on the layout manager + if (moveFreely) { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } else if (recyclerView.getLayoutManager() instanceof GridLayoutManager) { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + final int swipeFlags = 0; + return makeMovementFlags(dragFlags, swipeFlags); + } else if (recyclerView.getLayoutManager() instanceof LinearLayoutManager) { + LinearLayoutManager linear = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (linear.getOrientation() == RecyclerView.HORIZONTAL) { + final int dragFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } else { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } + } else { + final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + final int swipeFlags = ItemTouchHelper.ACTION_STATE_IDLE; + return makeMovementFlags(dragFlags, swipeFlags); + } + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + // Notify the adapter of the move + mAdapter.onItemMove(source.getAdapterPosition(), target.getAdapterPosition()); + return true; + } + + @Override + public void onMoved(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, int fromPos, RecyclerView.ViewHolder target, int toPos, int x, int y) { + super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y); + viewHolder.itemView.setAlpha(1f); + target.itemView.setAlpha(1f); + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) { + // Notify the adapter of the dismissal + mAdapter.onItemDismiss(viewHolder.getAdapterPosition()); + } + + @Override + public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + // Fade out the view as it is swiped out of the parent's bounds + final float alpha = ALPHA_FULL - Math.abs(dX) / (float) viewHolder.itemView.getWidth(); + viewHolder.itemView.setAlpha(alpha); + viewHolder.itemView.setTranslationX(dX); + } else if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + if (isCurrentlyActive) { + viewHolder.itemView.setAlpha(0.5f); + viewHolder.itemView.setScaleX(moveScaleFactor); + viewHolder.itemView.setScaleY(moveScaleFactor); + } else { + viewHolder.itemView.setAlpha(1f); + viewHolder.itemView.setScaleX(1f); + viewHolder.itemView.setScaleY(1f); + } + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } else { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } + + // 从拖动到释放的过程 + if (null != mOnSelectChangedListener && !isCurrentlyActive && lastActive) { + mOnSelectChangedListener.onSelectedChanged(viewHolder, dX, dY, actionState, isCurrentlyActive); + } + lastActive = isCurrentlyActive; + } + + @Override + public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) { + // We only want the active item to change + if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) { + if (viewHolder instanceof ItemTouchHelperViewHolder) { + // Let the view holder know that this item is being moved or dragged + ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder; + itemViewHolder.onItemSelected(); + } + } + super.onSelectedChanged(viewHolder, actionState); + + if (null != mOnSelectChangedListener) { + mOnSelectChangedListener.onSelectedChanged(viewHolder, actionState); + } + } + + @Override + public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.setAlpha(ALPHA_FULL); + if (viewHolder instanceof ItemTouchHelperViewHolder) { + // Tell the view holder it's time to restore the idle state + ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder; + itemViewHolder.onItemClear(); + } + } + + public void setMoveFreely(boolean moveFreely) { + this.moveFreely = moveFreely; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java new file mode 100644 index 0000000..8576f58 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/presenter/IPickerPresenter.java @@ -0,0 +1,168 @@ +package com.remax.visualnovel.widget.imagepicker.presenter; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +import com.remax.visualnovel.widget.imagepicker.adapter.PickerItemAdapter; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.data.ICameraExecutor; +import com.remax.visualnovel.widget.imagepicker.data.IReloadExecutor; +import com.remax.visualnovel.widget.imagepicker.data.ProgressSceneEnum; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; + +import java.io.Serializable; +import java.util.ArrayList; + +/** + * Time: 2019/10/27 22:22 + * Author:ypx + * Description: 选择器交互接口类 + * + *

+ * 实现该接口可快速定制属于你自己的选择器样式。该接口支持如下操作: + *

+ * 1.自定义图片加载逻辑和框架 + * 2.自定义选择器所有ui样式 + * 3.自定义提示 + * 4.自定义超出最大选择数量的提示 + * 5.自定义媒体库扫描和剪裁的加载框loading + * 6.自定义选择器完成按钮点击事件的拦截 + * 7.拦截选择器取消操作,用于弹出二次确认框 + * 8.图片点击事件拦截,如果返回true,则不会执行选中操纵,如果要拦截此事件并且要执行选中 + * 9.拍照点击事件拦截 + *

+ */ +public interface IPickerPresenter extends Serializable { + /** + * 图片加载,在安卓10上,外部存储的图片路径只能用Uri加载,私有目录的图片可以用绝对路径加载 + * 所以这个方法务必需要区分有uri和无uri的情况 + * 一般媒体库直接扫描出来的图片是含有uri的,而剪裁生成的图片保存在私有目录中,因此没有uri,只有绝对路径 + * 所以这里需要做一个兼容处理,例如如下代码: + * + *

+ * if (item.getUri() != null) { + * Glide.with(view.getContext()).load(item.getUri().into((ImageView) view); + * } else { + * Glide.with(view.getContext()).load(item.path).into((ImageView) view); + * } + *

+ * + * @param view imageView + * @param item 图片信息 + * @param size 加载尺寸 + * @param isThumbnail 是否是缩略图 + */ + void displayImage(View view, ImageItem item, int size, boolean isThumbnail); + + /** + * 设置自定义ui显示样式 + * 该方法返回一个PickerUiConfig对象 + * + *

+ * 该对象可以配置如下信息: + * 1.主题色 + * 2.相关页面背景色 + * 3.选择器标题栏,底部栏,item,文件夹列表item,预览页面,剪裁页面的定制 + *

+ *

+ * 详细使用方法参考 (@link https://github.com/yangpeixing/YImagePicker/blob/master/YPX_ImagePicker_androidx/app/src/main/java/com/ypx/imagepickerdemo/style/WeChatPresenter.java) + * + * @param context 上下文 + * @return PickerUiConfig + */ + @NonNull + PickerUiConfig getUiConfig(@Nullable Context context); + + /** + * 提示 + * + * @param context 上下文 + * @param msg 提示文本 + */ + void tip(@Nullable Context context, String msg); + + /** + * 选择超过数量限制提示 + * + * @param context 上下文 + * @param maxCount 最大数量 + */ + void overMaxCountTip(@Nullable Context context, int maxCount); + + /** + * 显示loading加载框,注意需要调用show方法 + * + * @param activity 启动加载框的activity + * @param progressSceneEnum {@link ProgressSceneEnum} + * + *

+ * 当progressSceneEnum==当ProgressSceneEnum.loadMediaItem 时,代表在加载媒体文件时显示加载框 + * 目前框架内规定,当文件夹内媒体文件少于1000时,强制不显示加载框,大于1000时才会执行此方法 + *

+ *

+ * 当progressSceneEnum==当ProgressSceneEnum.crop 时,代表是剪裁页面的加载框 + *

+ * @return DialogInterface 对象,用于关闭加载框,返回null代表不显示加载框 + */ + DialogInterface showProgressDialog(@Nullable Activity activity, ProgressSceneEnum progressSceneEnum); + + /** + * 拦截选择器完成按钮点击事件 + * + * @param activity 当前选择器activity + * @param selectedList 已选中的列表 + * @return true:则拦截选择器完成回调, false,执行默认的选择器回调 + */ + boolean interceptPickerCompleteClick(@Nullable Activity activity, ArrayList selectedList, BaseSelectConfig selectConfig); + + /** + * 拦截选择器取消操作,用于弹出二次确认框 + * + * @param activity 当前选择器页面 + * @param selectedList 当前已经选择的文件列表 + * @return true:则拦截选择器取消, false,不处理选择器取消操作 + */ + boolean interceptPickerCancel(@Nullable Activity activity, ArrayList selectedList); + + /** + *

+ * 图片点击事件拦截,如果返回true,则不会执行选中操纵,如果要拦截此事件并且要执行选中 + * 请调用如下代码: + *

+ * adapter.preformCheckItem() + *

+ *

+ * 此方法可以用来跳转到任意一个页面,比如自定义的预览 + * + * @param activity 上下文 + * @param imageItem 当前图片 + * @param selectImageList 当前选中列表 + * @param allSetImageList 当前文件夹所有图片 + * @param selectConfig 选择器配置项,如果是微信样式,则selectConfig继承自MultiSelectConfig + * 如果是小红书剪裁样式,则继承自CropSelectConfig + * @param adapter 当前列表适配器,用于刷新数据 + * @param isClickCheckBox 是否点击item右上角的选中框 + * @param reloadExecutor 刷新器 + * @return 是否拦截 + */ + boolean interceptItemClick(@Nullable Activity activity, ImageItem imageItem, ArrayList selectImageList, + ArrayList allSetImageList, BaseSelectConfig selectConfig, PickerItemAdapter adapter, + boolean isClickCheckBox, + @Nullable IReloadExecutor reloadExecutor); + + /** + * 拍照点击事件拦截 + * + * @param activity 当前activity + * @param takePhoto 拍照接口 + * @return 是否拦截 + */ + boolean interceptCameraClick(@Nullable Activity activity, ICameraExecutor takePhoto); +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java new file mode 100644 index 0000000..9ba69ca --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/MediaUtils.java @@ -0,0 +1,240 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; + +import com.remax.visualnovel.app.base.app.CommonApplicationProxy; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; + +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class MediaUtils { + + + public static String copyUriToLocalMedia(Context context, ImageItem imageItem, String path) { + if (imageItem == null) { + return null; + } + return copyUriToLocalMedia(context, imageItem.getUri(), path, imageItem.mimeType); + } + + /** + * 从相册中copy出文件并预处理 TODO 应该在异步线程中执行 + * + * @param context + * @param uri + * @param path 文件保存路径 + * @return + */ + public static String copyUriToLocalMedia(Context context, Uri uri, String path, String mimeType) { + if (uri == null) { + return null; + } + String filePath = ""; + try { + if (MimeType.isImage(mimeType)) { + StringBuilder timeStamp = new StringBuilder(); + timeStamp.append("aos"); + timeStamp.append(System.currentTimeMillis()); + for (int i = 0; i < 4; i++) { + timeStamp.append((int) (Math.random() * 10)); + } + String postfix = "jpg"; + if (MimeType.isGif(mimeType)) { + postfix = "gif"; + } + String filename = String.format("%s.%s", timeStamp, postfix); + filePath = path + "/" + filename; + File file = new File(filePath); + copyFileToInternalStorage(context, uri, file); + } else if (MimeType.isVideo(mimeType)) { + String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.getDefault()).format(new Date()); + String filename = String.format("VIDEO_%s.mp4", timeStamp); + filePath = path + "/" + filename; + File file = new File(filePath); + copyFileToInternalStorage(context, uri, file); + } else if (mimeType.startsWith("audio/mpeg")) { + String timeStamp = new SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.getDefault()).format(new Date()); + String filename = String.format("MP3_%s.mp3", timeStamp); + filePath = path + "/" + filename; + File file = new File(filePath); + copyFileToInternalStorage(context, uri, file); + } + } catch (Exception ignored) { + } + return filePath; + } + + /** + * 复制文件到内部存储空间 + */ + public static void copyFileToInternalStorage(Context context, Uri uri, File destinationFile) { + InputStream inputStream = null; + OutputStream outputStream = null; + + try { + inputStream = context.getContentResolver().openInputStream(uri); + outputStream = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ? Files.newOutputStream(destinationFile.toPath()) : new FileOutputStream(destinationFile); + + byte[] buffer = new byte[4096]; + int bytesRead; + while (inputStream !=null && (bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + // 关闭流 + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (outputStream != null) { + try { + outputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + public static void copyPublicDirToPackageDir(Context context, Uri uri, File file) { + InputStream is = null; + OutputStream os = null; + try { + ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r"); + is = new FileInputStream(pfd.getFileDescriptor()); + os = new FileOutputStream(file); + byte[] buf = new byte[2048]; + int len = 0; + while ((len = is.read(buf)) != -1) { // 循环从输入流读取 buffer字节 + os.write(buf, 0, len); // 将读取的输入流写入到输出流 + } + os.flush(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + closeIO(os, is); + } + } + + /** + * 关闭IO + * + * @param closeables closeable + */ + public static void closeIO(Closeable... closeables) { + if (closeables == null) { + return; + } + try { + for (Closeable closeable : closeables) { + if (closeable != null) { + closeable.close(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static String getExternalFilePath(Context context, String fileDir) { + String dirPath = context.getExternalFilesDir(fileDir).getAbsolutePath(); + File file = new File(dirPath); + if (!file.exists()) { + file.mkdirs(); + } + return dirPath; + } + + /** + * 删除目录 + * + * @param dirFile 目录 + * @return {@code true}: 删除成功
{@code false}: 删除失败 + */ + public static boolean deleteDir(File dirFile) { + if (dirFile == null) { + return false; + } + // 目录不存在返回true + if (!dirFile.exists()) { + return true; + } + // 不是目录返回false + if (!dirFile.isDirectory()) { + return false; + } + // 现在文件存在且是文件夹 + File[] files = dirFile.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isFile()) { + if (!deleteFile(file)) { + return false; + } + } else if (file.isDirectory()) { + if (!deleteDir(file)) { + return false; + } + } + } + } + return dirFile.delete(); + } + + /** + * 删除文件 + * + * @param file 文件 + * @return {@code true}: 删除成功
{@code false}: 删除失败 + */ + public static boolean deleteFile(File file) { + return file != null && (!file.exists() || file.isFile() && file.delete()); + } + + /** + * 获取一个临时文件存储地址(保存的文件会在app下次启动时删除) + * + * @return filePath + */ + public static String getTempFilePath() { + String filePath; + Context context = CommonApplicationProxy.INSTANCE.getApplication(); + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) || !Environment.isExternalStorageRemovable()) { + //外部存储可用 + filePath = getExternalFilePath(context, "temp"); + } else { + //外部存储不可用 + filePath = context.getDir("temp", Context.MODE_PRIVATE).getAbsolutePath(); + File file = new File(filePath); + if (!file.exists()) { + file.mkdirs(); + } + } + return filePath; + } + + public static void cleanTempFile(Context context) { + deleteDir(context.getExternalFilesDir("temp")); + deleteDir(context.getDir("temp", Context.MODE_PRIVATE)); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java new file mode 100644 index 0000000..3df17b3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PBitmapUtils.java @@ -0,0 +1,467 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.view.View; + +import androidx.exifinterface.media.ExifInterface; + +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.remax.visualnovel.widget.imagepicker.bean.MimeType; +import com.remax.visualnovel.widget.imagepicker.bean.UriPathInfo; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.FileNameMap; +import java.net.URLConnection; + +/** + * Time: 2019/7/17 14:16 + * Author:ypx + * Description:文件工具类 + */ +public class PBitmapUtils { + /** + * 根据相对路径获取图片宽高 + * + * @param c 上下文 + * @param uri 图片uri地址 + * @return 宽高信息 + */ + + public static int[] getImageWidthHeight(Context c, Uri uri) { + try { + ParcelFileDescriptor parcelFileDescriptor = c.getContentResolver() + .openFileDescriptor(uri, "r"); + if (parcelFileDescriptor != null) { + FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor); + parcelFileDescriptor.close(); + return new int[]{image.getWidth(), image.getHeight()}; + } + } catch (Exception e) { + e.printStackTrace(); + } + + return new int[]{0, 0}; + } + + public static Bitmap getBitmapFromUri(Context c, Uri uri) { + try { + ParcelFileDescriptor parcelFileDescriptor = c.getContentResolver() + .openFileDescriptor(uri, "r"); + if (parcelFileDescriptor != null) { + FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor); + parcelFileDescriptor.close(); + return image; + } + } catch (Exception e) { + e.printStackTrace(); + } + + return null; + } + + + /** + * 根据绝对路径得到图片的宽高,亲测比楼上速度快几十倍 + * + * @param imageLocalPath 绝对路径!绝对路径!绝对路径! + * @return 宽高 + */ + public static int[] getImageWidthHeight(String imageLocalPath) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(imageLocalPath, options); + int width = options.outWidth; + int height = options.outHeight; + + int orientation = getImageOrientation(imageLocalPath); + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + case ExifInterface.ORIENTATION_ROTATE_270: { + return new int[]{height, width}; + } + default: { + return new int[]{width, height}; + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return new int[]{0, 0}; + } + + + public static int getImageOrientation(String imageLocalPath) { + try { + ExifInterface exifInterface = new ExifInterface(imageLocalPath); + int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + return orientation; + } catch (IOException e) { + e.printStackTrace(); + return ExifInterface.ORIENTATION_NORMAL; + } + } + + /** + * @param context 上下文 + * @return 获取app私有目录 + */ + public static File getPickerFileDirectory(Context context) { + File file = new File(context.getExternalFilesDir(null), ImagePicker.DEFAULT_FILE_NAME); + if (!file.exists()) { + if (file.mkdirs()) { + return file; + } + } + return file; + } + + /** + * 获取系统相册文件路径 + */ + public static File getDCIMDirectory() { + File dcim = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); + if (!dcim.exists()) { + dcim = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); + } + return dcim; + } + + + /** + * androidQ中默认项目私有文件只能存储在Android/data/包名/files/下 + * + * @param context 上下文 + * @param bitmap 要保存的bitmap + * @param fileName 图片名称 + * @param compressFormat 图片格式 + * @return 该图片的绝对路径,不是Uri相对路径 + */ + public static String saveBitmapToFile(Context context, + Bitmap bitmap, + String fileName, + Bitmap.CompressFormat compressFormat) { + + File file = getPickerFileDirectory(context); + file = new File(file, fileName + "." + compressFormat.toString().toLowerCase()); + try { + FileOutputStream b = new FileOutputStream(file); + bitmap.compress(compressFormat, 90, b);// 把数据写入文件 + b.flush(); + b.close(); + return file.getAbsolutePath(); + } catch (Exception e) { + e.printStackTrace(); + if (file.exists()) { + file.delete(); + } + return "Exception:" + e.getMessage(); + } + } + + public static Uri saveBitmapToDCIM(Context context, + Bitmap bitmap, + String fileName, + Bitmap.CompressFormat compressFormat) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/" + compressFormat.toString()); + contentValues.put(MediaStore.Files.FileColumns.WIDTH, bitmap.getWidth()); + contentValues.put(MediaStore.Files.FileColumns.HEIGHT, bitmap.getHeight()); + String suffix = "." + compressFormat.toString().toLowerCase(); + String path = getDCIMDirectory().getAbsolutePath() + File.separator + fileName + suffix; +// try { +// contentValues.put(MediaStore.Images.Media.DATA, path); +// } catch (Exception ignored) { +// +// } + //执行insert操作,向系统文件夹中添加文件 + //EXTERNAL_CONTENT_URI代表外部存储器,该值不变 + Uri uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues); + if (uri != null) { + //若生成了uri,则表示该文件添加成功 + //使用流将内容写入该uri中即可 + try { + OutputStream outputStream = context.getContentResolver().openOutputStream(uri); + if (outputStream != null) { + bitmap.compress(compressFormat, 90, outputStream); + outputStream.flush(); + outputStream.close(); + return uri; + } + } catch (Exception e) { + e.printStackTrace(); + return uri; + } + } + + return uri; + } + + /** + * Uri路径转绝对路径 + * + * @param context 上下文,用于提供getContentResolver + * @param uri 要查询的uri + * @return 绝对路径 + */ + public static String getPathFromUri(Context context, Uri uri) { + String path = ""; + String DATA = Build.VERSION.SDK_INT < 29 ? + MediaStore.Images.ImageColumns.DATA + : MediaStore.Images.ImageColumns.RELATIVE_PATH; + Cursor cursor = context.getContentResolver().query(uri, new String[]{DATA}, + null, null, null); + if (null != cursor) { + if (cursor.moveToFirst()) { + int index = cursor.getColumnIndex(DATA); + if (index > -1) + path = cursor.getString(index); + } + cursor.close(); + } + return path; + } + + /** + * androidQ方式保存一张bitmap到DCIM根目录下 + * + * @param context 当前context + * @param sourceFilePath 当前要生成的bitmap + * @param fileName 图片名称 + * @param mimeType 图片格式 + * @return 此图片的Uri + */ + public static UriPathInfo copyFileToDCIM(Context context, String sourceFilePath, + String fileName, MimeType mimeType) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.Images.Media.MIME_TYPE, mimeType.toString()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentValues.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()); + } + boolean isImage = MimeType.isImage(mimeType.toString()); + if (isImage) { + int[] size = getImageWidthHeight(sourceFilePath); + contentValues.put(MediaStore.Files.FileColumns.WIDTH, size[0]); + contentValues.put(MediaStore.Files.FileColumns.HEIGHT, size[1]); + } else { + long duration = PBitmapUtils.getLocalVideoDuration(sourceFilePath); + contentValues.put("duration", duration); + } + String suffix = "." + mimeType.getSuffix(); + String path = getDCIMDirectory().getAbsolutePath() + File.separator + fileName + suffix; +// try { +// contentValues.put(MediaStore.Images.Media.DATA, path); +// } catch (Exception ignored) { +// +// } + //执行insert操作,向系统文件夹中添加文件 + //EXTERNAL_CONTENT_URI代表外部存储器,该值不变 + Uri uri = context.getContentResolver().insert(isImage ? MediaStore.Images.Media.EXTERNAL_CONTENT_URI : + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues); + copyFile(context, sourceFilePath, uri); + return new UriPathInfo(uri, path); + } + + private static boolean copyFile(Context context, String sourceFilePath, final Uri insertUri) { + if (insertUri == null) { + return false; + } + ContentResolver resolver = context.getContentResolver(); + InputStream is = null;//输入流 + OutputStream os = null;//输出流 + try { + os = resolver.openOutputStream(insertUri); + if (os == null) { + return false; + } + File sourceFile = new File(sourceFilePath); + if (sourceFile.exists()) { // 文件存在时 + is = new FileInputStream(sourceFile); // 读入原文件 + //输入流读取文件,输出流写入指定目录 + return copyFileWithStream(os, is); + } + return false; + } catch (Exception e) { + e.printStackTrace(); + return false; + } finally { + try { + if (is != null) { + is.close(); + } + if (os != null) { + os.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + private static boolean copyFileWithStream(OutputStream os, InputStream is) { + if (os == null || is == null) { + return false; + } + int read = 0; + while (true) { + try { + byte[] buffer = new byte[1444]; + while ((read = is.read(buffer)) != -1) { + os.write(buffer, 0, read); + os.flush(); + } + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } finally { + try { + os.close(); + is.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + /** + * @return view的截图,在InVisible时也可以获取到bitmap + */ + public static Bitmap getViewBitmap(View view) { + view.measure(View.MeasureSpec.makeMeasureSpec(view.getMeasuredWidth(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(view.getMeasuredHeight(), View.MeasureSpec.EXACTLY)); + view.setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_HIGH); + view.setDrawingCacheEnabled(true); + view.buildDrawingCache(true); + return view.getDrawingCache(true); + } + + + /** + * 获取视频封面 + */ + public static Bitmap getVideoThumb(String path) { + MediaMetadataRetriever media = new MediaMetadataRetriever(); + media.setDataSource(path); + return media.getFrameAtTime(); + } + + /** + * 获取视频时长 + */ + public static long getLocalVideoDuration(String videoPath) { + int duration; + try { + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + mmr.setDataSource(videoPath); + duration = Integer.parseInt(mmr.extractMetadata + (MediaMetadataRetriever.METADATA_KEY_DURATION)); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + return duration; + } + + /** + * 刷新相册 + */ + public static void refreshGalleryAddPic(Context context, Uri uri) { + if (context == null) { + return; + } + Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + mediaScanIntent.setData(uri); + context.sendBroadcast(mediaScanIntent); + } + + public static String getMimeTypeFromUri(Activity context, Uri uri) { + ContentResolver resolver = context.getContentResolver(); + return resolver.getType(uri); + } + + public static String getMimeTypeFromPath(String path) { + FileNameMap fileNameMap = URLConnection.getFileNameMap(); + return fileNameMap.getContentTypeFor(new File(path).getName()); + } + + + public static Uri getImageContentUri(Context context, String path) { + Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{MediaStore.Images.Media._ID}, MediaStore.Images.Media.DATA + "=? ", + new String[]{path}, null); + if (cursor != null && cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); + if (columnIndex != -1) { + int id = cursor.getInt(columnIndex); + Uri baseUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + cursor.close(); + return Uri.withAppendedPath(baseUri, "" + id); + } else { + return null; + } + } else { + return null; + } + } + + public static Uri getVideoContentUri(Context context, String path) { + Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{MediaStore.Images.Media._ID}, MediaStore.Images.Media.DATA + "=? ", + new String[]{path}, null); + if (cursor != null && cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID); + if (columnIndex != -1) { + int id = cursor.getInt(columnIndex); + Uri baseUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + cursor.close(); + return Uri.withAppendedPath(baseUri, "" + id); + } else { + return null; + } + } else { + return null; + } + } + + public static Uri getContentUri(String mimeType, long id) { + if (id <= 0) { + return null; + } + Uri contentUri; + if (MimeType.isImage(mimeType)) { + contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } else if (MimeType.isVideo(mimeType)) { + contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + } else { + contentUri = MediaStore.Files.getContentUri("external"); + } + return ContentUris.withAppendedId(contentUri, id); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java new file mode 100644 index 0000000..e756575 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PCornerUtils.java @@ -0,0 +1,39 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; + +/** + * Utils to get corner drawable + */ +public class PCornerUtils { + public static Drawable cornerDrawable(final int bgColor, float cornerradius) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(cornerradius); + bg.setColor(bgColor); + return bg; + } + + public static Drawable cornerDrawableAndStroke(final int bgColor, float cornerradius, int strokeWidth, int strokeColor) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(cornerradius); + bg.setColor(bgColor); + bg.setStroke(strokeWidth, strokeColor); + return bg; + } + + public static Drawable cornerDrawable(float cornerradius) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(cornerradius); + return bg; + } + + public static Drawable cornerDrawable(final int bgColor, float[] cornerradius) { + final GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadii(cornerradius); + bg.setColor(bgColor); + + return bg; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java new file mode 100644 index 0000000..161fc04 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PDateUtil.java @@ -0,0 +1,153 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.annotation.SuppressLint; +import android.content.Context; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.utils.TimeUtils; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; + + +/** + * 时间工具类 + */ +@SuppressLint("SimpleDateFormat") +public class PDateUtil { + + public static String getStrTime(Context context, long cc_time) { + if (cc_time == 0) { + return ""; + } + if (String.valueOf(cc_time).length() <= 10) { + cc_time = cc_time * 1000L; + } + Date date = new Date(cc_time); + if (isToday(date)) { + return context.getString(R.string.picker_str_today); + } + if (isThisWeek(date)) { + return context.getString(R.string.picker_str_this_week); + } + if (isThisMonth(date)) { + return context.getString(R.string.picker_str_this_months); + } + return new SimpleDateFormat(context.getString(R.string.picker_str_time_format)).format(date); + } + + //判断选择的日期是否是本周 + private static boolean isThisWeek(Date date) { + Calendar calendar = Calendar.getInstance(); + int currentWeek = calendar.get(Calendar.WEEK_OF_YEAR); + calendar.setTime(date); + int paramWeek = calendar.get(Calendar.WEEK_OF_YEAR); + return paramWeek == currentWeek; + } + + //判断选择的日期是否是今天 + private static boolean isToday(Date date) { + return isThisTime(date, TimeUtils.YMD_PATTERN); + } + + //判断选择的日期是否是本月 + private static boolean isThisMonth(Date date) { + return isThisTime(date, TimeUtils.YM_PATTERN); + } + + private static boolean isThisTime(Date date, String pattern) { + SimpleDateFormat sdf = new SimpleDateFormat(pattern); + String param = sdf.format(date);//参数时间 + String now = sdf.format(new Date());//当前时间 + return param.equals(now); + } + + + /** + * 获取视频时长(格式化) + */ + public static String getVideoDuration(long timestamp) { + if (timestamp < 1000) { + return "00:01"; + } + Date date = new Date(timestamp); + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss"); + return simpleDateFormat.format(date); + } + + + /* + * 毫秒转化 + */ + public static String formatTime(Context context, Long ms) { + Integer ss = 1000; + Integer mi = ss * 60; + Integer hh = mi * 60; + Integer dd = hh * 24; + +// Long day = ms / dd; +// Long hour = (ms - day * dd) / hh; +// Long minute = (ms - day * dd - hour * hh) / mi; +// Long second = (ms - day * dd - hour * hh - minute * mi) / ss; +// long milliSecond = ms - day * dd - hour * hh - minute * mi - second * ss; +// +// StringBuilder sb = new StringBuilder(); +// if (day > 0) { +// sb.append(day).append(context.getString(R.string.picker_str_day)); +// } +// if (hour > 0) { +// sb.append(hour).append(context.getString(R.string.picker_str_hour)); +// } +// if (minute > 0) { +// sb.append(minute).append(context.getString(R.string.picker_str_minute)); +// } +// if (second > 0) { +// sb.append(second).append(context.getString(R.string.picker_str_second)); +// } +// if (milliSecond > 0) { +// sb.append(milliSecond).append(context.getString(R.string.picker_str_milli)); +// } + + Long day = ms / dd; + Long hour = (ms - day * dd) / hh; + Long minute = (ms - day * dd - hour * hh) / mi; + Long second = (ms - day * dd - hour * hh - minute * mi) / ss; + + StringBuilder sb = new StringBuilder(); + if (day > 0) { + sb.append(day).append(context.getString(R.string.picker_str_unit)); + } + if (hour > 0) { + if (hour > 9) { + sb.append(hour).append(context.getString(R.string.picker_str_unit)); + } else { + sb.append("0").append(hour).append(context.getString(R.string.picker_str_unit)); + } + } + if (minute > 0) { + if (minute > 9) { + sb.append(minute).append(context.getString(R.string.picker_str_unit)); + } else { + sb.append("0").append(minute).append(context.getString(R.string.picker_str_unit)); + } + } + if (second >= 0) { + if (minute == 0) { + if (second > 9) { + sb.append("00:").append(second); + } else { + sb.append("00:0").append(second); + } + } else { + if (second > 9) { + sb.append(second); + } else { + sb.append("0").append(second); + } + } + } + + return sb.toString(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java new file mode 100644 index 0000000..0ef2d35 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PPermissionUtils.java @@ -0,0 +1,162 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.Manifest; +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.text.TextUtils; + +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; + +import com.remax.visualnovel.BuildConfig; +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; +import com.hjq.permissions.permission.PermissionNames; + + +/** + * Description: 权限工具类 + *

+ * Author: peixing.yang + * Date: 2019/3/1 + */ +public class PPermissionUtils { + private Context context; + + public static boolean hasCameraPermissions(Activity activity) { + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions(new String[]{Manifest.permission.CAMERA}, ImagePicker.REQ_CAMERA); + return false; + } + return true; + } + + public static boolean hasStoragePermissions(Activity activity) { + String permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? PermissionNames.READ_MEDIA_IMAGES : PermissionNames.WRITE_EXTERNAL_STORAGE; + if (ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions(new String[]{permission}, ImagePicker.REQ_STORAGE); + return false; + } + return true; + } + + public static PPermissionUtils create(Context context) { + return new PPermissionUtils(context); + } + + public PPermissionUtils(Context context) { + this.context = context; + } + + public void showSetPermissionDialog(final String msg) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setMessage(msg); + builder.setCancelable(false); + builder.setNegativeButton(context.getString(R.string.picker_str_permission_refuse_setting), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + if (msg.equals(context.getString(R.string.picker_str_storage_permission))) { + ((Activity) context).finish(); + } + } + }); + builder.setPositiveButton(context.getString(R.string.picker_str_permission_go_setting), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dialogInterface.dismiss(); + gotoPermissionSet(); + } + }); + AlertDialog dialog = builder.create(); + dialog.show(); + } + + public void gotoPermissionSet() { + String brand = Build.BRAND;//手机厂商 + if (TextUtils.equals(brand.toLowerCase(), "redmi") || TextUtils.equals(brand.toLowerCase(), "xiaomi")) { + gotoMiuiPermission();//小米 + } else if (TextUtils.equals(brand.toLowerCase(), "meizu")) { + gotoMeizuPermission(); + } else if (TextUtils.equals(brand.toLowerCase(), "huawei") || TextUtils.equals(brand.toLowerCase(), "honor")) { + gotoHuaweiPermission(); + } else { + context.startActivity(getAppDetailSettingIntent()); + } + } + + /** + * 跳转到miui的权限管理页面 + */ + private void gotoMiuiPermission() { + try { // MIUI 8 + Intent localIntent = new Intent("miui.intent.action.APP_PERM_EDITOR"); + localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.PermissionsEditorActivity"); + localIntent.putExtra("extra_pkgname", context.getPackageName()); + context.startActivity(localIntent); + } catch (Exception e) { + try { // MIUI 5/6/7 + Intent localIntent = new Intent("miui.intent.action.APP_PERM_EDITOR"); + localIntent.setClassName("com.miui.securitycenter", "com.miui.permcenter.permissions.AppPermissionsEditorActivity"); + localIntent.putExtra("extra_pkgname", context.getPackageName()); + context.startActivity(localIntent); + } catch (Exception e1) { // 否则跳转到应用详情 + context.startActivity(getAppDetailSettingIntent()); + } + } + } + + /** + * 跳转到魅族的权限管理系统 + */ + private void gotoMeizuPermission() { + try { + Intent intent = new Intent("com.meizu.safe.security.SHOW_APPSEC"); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.putExtra("packageName", BuildConfig.APPLICATION_ID); + context.startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + context.startActivity(getAppDetailSettingIntent()); + } + } + + /** + * 华为的权限管理页面 + */ + private void gotoHuaweiPermission() { + try { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ComponentName comp = new ComponentName("com.huawei.systemmanager", "com.huawei.permissionmanager.ui.MainActivity");//华为权限管理 + intent.setComponent(comp); + context.startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + context.startActivity(getAppDetailSettingIntent()); + } + + } + + /** + * 获取应用详情页面intent(如果找不到要跳转的界面,也可以先把用户引导到系统设置页面) + * + * @return + */ + private Intent getAppDetailSettingIntent() { + Intent localIntent = new Intent(); + localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + localIntent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS"); + localIntent.setData(Uri.fromParts("package", context.getPackageName(), null)); + return localIntent; + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java new file mode 100644 index 0000000..8242f67 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PSingleMediaScanner.java @@ -0,0 +1,43 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.content.Context; +import android.media.MediaScannerConnection; +import android.net.Uri; + +/** + * @author yangpeixing + * 媒体扫描刷新类 + */ +public class PSingleMediaScanner implements MediaScannerConnection.MediaScannerConnectionClient { + private MediaScannerConnection mediaScannerConnection; + private String mPath; + private ScanListener mListener; + + public interface ScanListener { + void onScanFinish(); + } + + public PSingleMediaScanner(Context context, String mPath, ScanListener mListener) { + this.mPath = mPath; + this.mListener = mListener; + this.mediaScannerConnection = new MediaScannerConnection(context, this); + this.mediaScannerConnection.connect(); + } + + @Override + public void onMediaScannerConnected() { + mediaScannerConnection.scanFile(mPath, null); + } + + @Override + public void onScanCompleted(String mPath, Uri mUri) { + mediaScannerConnection.disconnect(); + if (mListener != null) { + mListener.onScanFinish(); + } + } + + public static void refresh(Context context, String path, ScanListener scanListener) { + new PSingleMediaScanner(context.getApplicationContext(), path, scanListener); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java new file mode 100644 index 0000000..f3a7ef8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PStatusBarUtil.java @@ -0,0 +1,174 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.app.Activity; +import android.graphics.Color; +import android.os.Build; +import android.view.DisplayCutout; +import android.view.View; +import android.view.WindowManager; + +import java.lang.reflect.Method; + +/** + * 状态栏工具类 + */ +public class PStatusBarUtil { + + /** + * 是否有刘海屏 + */ + public static boolean hasNotchInScreen(Activity activity) { + // android P 以上有标准 API 来判断是否有刘海屏 + if (Build.VERSION.SDK_INT >= 28) { + try { + DisplayCutout displayCutout = activity.getWindow().getDecorView().getRootWindowInsets().getDisplayCutout(); + if (displayCutout != null) { + // 说明有刘海屏 + return true; + } + } catch (Exception e) { + return false; + } + } else { + // 通过其他方式判断是否有刘海屏 目前官方提供有开发文档的就 小米,vivo,华为(荣耀),oppo + String manufacturer = Build.MANUFACTURER; + if (manufacturer == null || manufacturer.length() == 0) { + return false; + } else if (manufacturer.equalsIgnoreCase("HUAWEI")) { + return hasNotchHw(activity); + } else if (manufacturer.equalsIgnoreCase("xiaomi")) { + return hasNotchXiaoMi(activity); + } else if (manufacturer.equalsIgnoreCase("oppo")) { + return hasNotchOPPO(activity); + } else if (manufacturer.equalsIgnoreCase("vivo")) { + return hasNotchVIVO(activity); + } else { + return false; + } + } + return false; + } + + /** + * 判断vivo是否有刘海屏 + * https://swsdl.vivo.com.cn/appstore/developer/uploadfile/20180328/20180328152252602.pdf + * + * @param activity + * @return + */ + public static boolean hasNotchVIVO(Activity activity) { + try { + Class c = Class.forName("android.util.FtFeature"); + Method get = c.getMethod("isFeatureSupport", int.class); + return (boolean) (get.invoke(c, 0x20)); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 判断oppo是否有刘海屏 + * https://open.oppomobile.com/wiki/doc#id=10159 + * + * @param activity + * @return + */ + public static boolean hasNotchOPPO(Activity activity) { + try { + return activity.getPackageManager().hasSystemFeature("com.oppo.feature.screen.heteromorphism"); + } catch (Exception e) { + e.printStackTrace(); + } + return false; + } + + /** + * 判断xiaomi是否有刘海屏 + * https://dev.mi.com/console/doc/detail?pId=1293 + * + * @param activity + * @return + */ + public static boolean hasNotchXiaoMi(Activity activity) { + try { + Class c = Class.forName("android.os.SystemProperties"); + Method get = c.getMethod("getInt", String.class, int.class); + return (int) (get.invoke(c, "ro.miui.notch", 1)) == 1; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 判断华为是否有刘海屏 + * https://devcenter-test.huawei.com/consumer/cn/devservice/doc/50114 + */ + public static boolean hasNotchHw(Activity activity) { + try { + ClassLoader cl = activity.getClassLoader(); + Class HwNotchSizeUtil = cl.loadClass("com.huawei.android.util.HwNotchSizeUtil"); + Method get = HwNotchSizeUtil.getMethod("hasNotchInScreen"); + return (boolean) get.invoke(HwNotchSizeUtil); + } catch (Exception e) { + return false; + } + } + + public static void setStatusBar(Activity activity, int bgColor, boolean isFullScreen, boolean isDarkStatusBarIcon) { + //5.0以下不处理 + if (Build.VERSION.SDK_INT < 21) { + return; + } + int option = 0; + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + //只有在6.0以上才改变状态栏颜色,否则在5.0机器上,电量条图标是白色的,标题栏也是白色的,就看不见电量条了了 + //在5.0上显示默认灰色背景色 + if (Build.VERSION.SDK_INT >= 23) { + // 设置状态栏底色颜色 + activity.getWindow().setStatusBarColor(bgColor); + //浅色状态栏,则让状态栏图标变黑,深色状态栏,则让状态栏图标变白 + if (isDarkStatusBarIcon) { + if (isFullScreen) { + option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + } else { + if (isFullScreen) { + option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + } else { + option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + } + } + } else { + if (isFullScreen) { + activity.getWindow().setStatusBarColor(Color.TRANSPARENT); + option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + } else { + activity.getWindow().setStatusBarColor(bgColor); + option = View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + } + } + activity.getWindow().getDecorView().setSystemUiVisibility(option); + } + + + /** + * 显示标题背景颜色 + */ + public static boolean isDarkColor(int colorInt) { + int gray = (int) (Color.red(colorInt) * 0.299 + Color.green(colorInt) * 0.587 + Color.blue(colorInt) * 0.114); + return gray >= 192; + } + + public static void fullScreen(Activity activity) { + activity.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java new file mode 100644 index 0000000..a359471 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PViewSizeUtils.java @@ -0,0 +1,254 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.content.Context; +import android.graphics.Color; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; + +import java.lang.ref.WeakReference; + +/** + * Description: View尺寸相关工具类 + *

+ * Author: peixing.yang + * Date: 2018/12/24-15:40 + */ +final public class PViewSizeUtils { + public static void setViewSize(View view, int width, int height) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params == null) { + params = new ViewGroup.LayoutParams(width, height); + } else { + if (width != -1) { + params.width = width; + } + if (height != -1) { + params.height = height; + } + } + viewWeakReference.get().setLayoutParams(params); + } + } + + + public static void setViewSize(View view, int width, float widthHeightRatio) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params == null) { + params = new ViewGroup.LayoutParams(width, (int) (width / widthHeightRatio)); + } else { + if (width != -1) { + params.width = width; + } + if (widthHeightRatio != 0) { + params.height = (int) (width / widthHeightRatio); + } + } + viewWeakReference.get().setLayoutParams(params); + } + } + + public static void setViewSize(View view, int width, int height, int marginLeft, int marginTop, int marginRight, int marginBottom) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + if (viewWeakReference.get().getLayoutParams() != null && + (viewWeakReference.get().getLayoutParams() instanceof ViewGroup.MarginLayoutParams)) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + params.width = width; + params.height = height; + if (marginLeft != -1) { + params.leftMargin = marginLeft; + } + if (marginRight != -1) { + params.rightMargin = marginRight; + } + if (marginTop != -1) { + params.topMargin = marginTop; + } + if (marginBottom != -1) { + params.bottomMargin = marginBottom; + } + viewWeakReference.get().setLayoutParams(params); + } + } + } + + + public static void setViewMargin(View view, int margin) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + if (viewWeakReference.get().getLayoutParams() != null && + (viewWeakReference.get().getLayoutParams() instanceof ViewGroup.MarginLayoutParams)) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (margin != -1) { + params.leftMargin = margin; + params.rightMargin = margin; + params.topMargin = margin; + params.bottomMargin = margin; + } + viewWeakReference.get().setLayoutParams(params); + } + } + } + + /** + * 获取View的高度 + * + * @param v view + * @return 高度 + */ + public static int getViewHeight(View v) { + ViewGroup.LayoutParams params = v.getLayoutParams(); + if (params != null) { + return params.height; + } + return v.getHeight(); + } + + /** + * 获取View的宽度 + * + * @param v view + * @return 宽度 + */ + public static int getViewWidth(View v) { + ViewGroup.LayoutParams params = v.getLayoutParams(); + if (params != null) { + return params.width; + } + return v.getWidth(); + } + + public static void setMarginStart(View view, int marginStart) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.leftMargin = marginStart; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static void setMarginStartAndEnd(View view, int marginStart, int marginEnd) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.leftMargin = marginStart; + params.rightMargin = marginEnd; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static void setMarginTopAndBottom(View view, int marginTop, int marginBottom) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.topMargin = marginTop; + params.bottomMargin = marginBottom; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static void setMarginTop(View view, int marginTop) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + params.topMargin = marginTop; + viewWeakReference.get().setLayoutParams(params); + } + } + } + + public static int getMarginTop(View view) { + WeakReference viewWeakReference = new WeakReference<>(view); + if (viewWeakReference.get() != null) { + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); + if (params != null) { + return params.topMargin; + } + } + return 0; + } + + public static int dp(Context context, float dp) { + if (context == null) { + return 0; + } + float density = context.getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + public static int sp(Context context, int spValue) { + final float fontScale = context.getResources().getDisplayMetrics().scaledDensity; + return (int) (spValue * fontScale + 0.5f); + } + + /** + * 获得屏幕宽度 + */ + public static int getScreenWidth(Context context) { + if (context == null) { + return 0; + } + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.widthPixels; + } + + /** + * 获得屏幕高度 + */ + public static int getScreenHeight(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.heightPixels; + } + + /** + * 两个颜色渐变转化 + * + * @param color1 默认色 + * @param color2 目标色 + * @param ratio 渐变率(0~1) + * @return 计算后的颜色 + */ + public static int blendColors(int color1, int color2, float ratio) { + final float inverseRation = 1f - ratio; + float r = (Color.red(color1) * ratio) + + (Color.red(color2) * inverseRation); + float g = (Color.green(color1) * ratio) + + (Color.green(color2) * inverseRation); + float b = (Color.blue(color1) * ratio) + + (Color.blue(color2) * inverseRation); + return Color.rgb((int) r, (int) g, (int) b); + } + + + private static long lastTime = 0L; + + public static boolean onDoubleClick() { + boolean flag = false; + long time = System.currentTimeMillis() - lastTime; + + if (time > 300) { + flag = true; + } + lastTime = System.currentTimeMillis(); + return !flag; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java new file mode 100644 index 0000000..7ed02a1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/utils/PickerFileProvider.java @@ -0,0 +1,30 @@ +package com.remax.visualnovel.widget.imagepicker.utils; + +import android.app.Activity; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + +import java.io.File; + +/** + * Time: 2019/7/24 15:43 + * Author:ypx + * Description: + */ +public class PickerFileProvider extends FileProvider { + + public static Uri getUriForFile(@NonNull Activity context, + @NonNull File file) { + Uri uri; + if (android.os.Build.VERSION.SDK_INT < 24) { + uri = Uri.fromFile(file); + } else { + uri = getUriForFile(context, context.getApplication().getPackageName() + + ".picker.fileprovider", file); + } + + return uri; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java new file mode 100644 index 0000000..7c143b6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiConfig.java @@ -0,0 +1,199 @@ +package com.remax.visualnovel.widget.imagepicker.views; + +import android.graphics.Color; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.ImagePicker; + + +/** + * Time: 2019/11/13 15:54 + * Author:ypx + * Description:选择器ui样式配置类 + */ +public class PickerUiConfig { + + /** + * 文件夹列表从上往下弹入 + */ + public static final int DIRECTION_TOP = 1; + + /** + * 文件夹列表从底部往上弹入 + */ + public static final int DIRECTION_BOTTOM = 2; + + //全局相关属性 + private int pickerBackgroundColor = Color.BLACK; + private int previewBackgroundColor = Color.BLACK; + private int singleCropBackgroundColor = Color.BLACK; + private int folderListOpenDirection = DIRECTION_TOP; + private int folderListOpenMaxMargin = 0; + private boolean isShowStatusBar; + private int statusBarColor; + private int videoPauseIconID; + + //小红书剪裁相关属性 + private int cropViewBackgroundColor = Color.BLACK; + private int fullIconID; + private int fitIconID; + private int gapIconID; + private int FillIconID; + + //选择器ui提供类 + private PickerUiProvider pickerUiProvider; + + /** + * 主题色 + */ + private int themeColor; + + public PickerUiProvider getPickerUiProvider() { + if (pickerUiProvider == null) { + return new PickerUiProvider(); + } + return pickerUiProvider; + } + + public void setPickerUiProvider(PickerUiProvider pickerUiProvider) { + this.pickerUiProvider = pickerUiProvider; + } + + public int getPickerBackgroundColor() { + if (pickerBackgroundColor == 0) { + return Color.WHITE; + } + return pickerBackgroundColor; + } + + public int getSingleCropBackgroundColor() { + return singleCropBackgroundColor; + } + + public void setSingleCropBackgroundColor(int singleCropBackgroundColor) { + this.singleCropBackgroundColor = singleCropBackgroundColor; + } + + public void setPickerBackgroundColor(int pickerBackgroundColor) { + this.pickerBackgroundColor = pickerBackgroundColor; + } + + public int getPreviewBackgroundColor() { + return previewBackgroundColor; + } + + public void setPreviewBackgroundColor(int previewBackgroundColor) { + this.previewBackgroundColor = previewBackgroundColor; + } + + public int getFolderListOpenDirection() { + return folderListOpenDirection; + } + + public void setFolderListOpenDirection(int folderListOpenDirection) { + this.folderListOpenDirection = folderListOpenDirection; + } + + public boolean isShowFromBottom() { + return folderListOpenDirection == DIRECTION_BOTTOM; + } + + public boolean isShowStatusBar() { + return isShowStatusBar; + } + + public void setShowStatusBar(boolean showStatusBar) { + isShowStatusBar = showStatusBar; + } + + public int getStatusBarColor() { + return statusBarColor; + } + + public void setStatusBarColor(int statusBarColor) { + this.statusBarColor = statusBarColor; + } + + public int getFolderListOpenMaxMargin() { + return folderListOpenMaxMargin; + } + + public void setFolderListOpenMaxMargin(int folderListOpenMaxHeight) { + this.folderListOpenMaxMargin = folderListOpenMaxHeight; + } + + public int getCropViewBackgroundColor() { + if (cropViewBackgroundColor == 0) { + return Color.BLACK; + } + return cropViewBackgroundColor; + } + + public void setCropViewBackgroundColor(int cropViewBackgroundColor) { + this.cropViewBackgroundColor = cropViewBackgroundColor; + } + + public int getFullIconID() { + if (fullIconID == 0) { + fullIconID = R.mipmap.picker_icon_full; + } + return fullIconID; + } + + public void setFullIconID(int fullIconID) { + this.fullIconID = fullIconID; + } + + public int getFitIconID() { + if (fitIconID == 0) { + fitIconID = R.mipmap.picker_icon_fit; + } + return fitIconID; + } + + public void setFitIconID(int fitIconID) { + this.fitIconID = fitIconID; + } + + public int getGapIconID() { + if (gapIconID == 0) { + gapIconID = R.mipmap.picker_icon_haswhite; + } + return gapIconID; + } + + public void setGapIconID(int gapIconID) { + this.gapIconID = gapIconID; + } + + public int getFillIconID() { + if (FillIconID == 0) { + FillIconID = R.mipmap.picker_icon_fill; + } + return FillIconID; + } + + public void setFillIconID(int fillIconID) { + FillIconID = fillIconID; + } + + public int getVideoPauseIconID() { + if (videoPauseIconID == 0) { + videoPauseIconID = R.mipmap.picker_icon_video; + } + return videoPauseIconID; + } + + public void setVideoPauseIconID(int videoPauseIconID) { + this.videoPauseIconID = videoPauseIconID; + } + + public int getThemeColor() { + return themeColor; + } + + public void setThemeColor(int themeColor) { + this.themeColor = themeColor; + ImagePicker.setThemeColor(themeColor); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java new file mode 100644 index 0000000..7ffa592 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/PickerUiProvider.java @@ -0,0 +1,77 @@ +package com.remax.visualnovel.widget.imagepicker.views; + +import android.content.Context; + +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerFolderItemView; +import com.remax.visualnovel.widget.imagepicker.views.base.PickerItemView; +import com.remax.visualnovel.widget.imagepicker.views.base.SingleCropControllerView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.BottomBar; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.FolderItemView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.ItemView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.PreviewControllerView; +import com.remax.visualnovel.widget.imagepicker.views.wrapper.TitleBar; + + +/** + * Time: 2019/10/27 22:22 + * Author:ypx + * Description: 选择器UI提供类,默认为微信样式 + */ +public class PickerUiProvider { + + /** + * 获取标题栏 + * + * @param context 调用此view的activity + */ + public PickerControllerView getTitleBar(Context context) { + return new TitleBar(context); + } + + /** + * 获取底部栏 + * + * @param context 调用此view的activity + * @return {@link PickerControllerView}对象,参考{ WXBottomBar} + */ + public PickerControllerView getBottomBar(Context context) { + return new BottomBar(context, false); + } + + /** + * 获取自定义item + * + * @param context 调用此view的activity + */ + public PickerItemView getItemView(Context context) { + return new ItemView(context); + } + + /** + * 获取自定义文件夹item + * + * @param context 调用此view的activity + */ + public PickerFolderItemView getFolderItemView(Context context) { + return new FolderItemView(context); + } + + /** + * 获取自定义预览界面 + * + * @param context 调用此view的activity + */ + public PreviewControllerView getPreviewControllerView(Context context) { + return new PreviewControllerView(context); + } + + /** + * 获取自定义单图剪裁界面 + * + * @param context 调用此view的activity + */ + public SingleCropControllerView getSingleCropControllerView(Context context) { + return new com.remax.visualnovel.widget.imagepicker.views.wrapper.SingleCropControllerView(context); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java new file mode 100644 index 0000000..bd93a10 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PBaseLayout.java @@ -0,0 +1,68 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.app.Activity; +import android.content.Context; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + + + +/** + * Time: 2019/11/11 14:33 + * Author:ypx + * Description:所有View的基类,其中包含了dp、getScreenWidth + */ +public abstract class PBaseLayout extends LinearLayout { + protected View view; + + /** + * @return item布局id + */ + protected abstract int getLayoutId(); + + /** + * @param view 初始化view + */ + protected abstract void initView(View view); + + public PBaseLayout(Context context) { + super(context); + init(); + } + + public PBaseLayout(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public PBaseLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + view = LayoutInflater.from(getContext()).inflate(getLayoutId(), this, true); + initView(view); + } + + protected int getScreenWidth() { + WindowManager wm = (WindowManager) getContext() + .getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics outMetrics = new DisplayMetrics(); + assert wm != null; + wm.getDefaultDisplay().getMetrics(outMetrics); + return outMetrics.widthPixels; + } + + public void onBackPressed() { + if (getContext() instanceof Activity) { + ((Activity) getContext()).onBackPressed(); + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java new file mode 100644 index 0000000..32145d3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerControllerView.java @@ -0,0 +1,86 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; + +import java.util.ArrayList; + +/** + * Time: 2019/11/7 13:24 + * Author:ypx + * Description: 选择器控制类 + */ +public abstract class PickerControllerView extends PBaseLayout { + + /** + * @return 获取当前view的高度 + */ + public abstract int getViewHeight(); + + /** + * @return 获取可以点击触发完成回调的View,如果返回null,则代表不可以触发完成回调 + */ + public abstract View getCanClickToCompleteView(); + + /** + * @return 获取可以跳转到预览的View,如果返回null,则代表不可触发跳转预览 + */ + public abstract View getCanClickToIntentPreviewView(); + + /** + * @return 获取可以切换文件夹列表的View,返回null,则不切换文件夹 + */ + public abstract View getCanClickToToggleFolderListView(); + + /** + * @param title 设置默认标题 + */ + public abstract void setTitle(String title); + + /** + * 切换文件夹 + * + * @param isOpen 当前是否是打开文件夹 + */ + public abstract void onTransitImageSet(boolean isOpen); + + /** + * 切换文件夹回调 + * + * @param imageSet 当前切换的文件夹 + */ + public abstract void onImageSetSelected(ImageSet imageSet); + + /** + * 刷新完成按钮状态 + * + * @param selectedList 已选中列表 + * @param selectConfig 选择器配置项 + */ + public abstract void refreshCompleteViewState(ArrayList selectedList, BaseSelectConfig selectConfig); + + public boolean isAddInParent() { + return getViewHeight() > 0; + } + + public PickerControllerView(Context context) { + super(context); + } + + public PickerControllerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PickerControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java new file mode 100644 index 0000000..5a54625 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerFolderItemView.java @@ -0,0 +1,51 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + + +/** + * Time: 2019/11/13 14:39 + * Author:ypx + * Description:自定义文件夹item + */ +public abstract class PickerFolderItemView extends PBaseLayout { + + /** + * @return 获取每个item的高度,如果自适应返回-1 + */ + public abstract int getItemHeight(); + + /** + * 加载文件夹缩略图 + * + * @param imageSet 文件夹 + * @param presenter presenter + */ + public abstract void displayCoverImage(ImageSet imageSet, IPickerPresenter presenter); + + /** + * 加载item + * + * @param imageSet 当前文件夹信息 + */ + public abstract void loadItem(ImageSet imageSet); + + public PickerFolderItemView(Context context) { + super(context); + } + + public PickerFolderItemView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PickerFolderItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java new file mode 100644 index 0000000..cef0cca --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PickerItemView.java @@ -0,0 +1,103 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; + + +/** + * Time: 2019/8/8 15:42 + * Author:ypx + * Description:自定义item基类 + *

+ * 执行流程: + * initItem——> enableItem ——> disableItem + *

+ */ +public abstract class PickerItemView extends PBaseLayout { + /** + * 获取拍照item样式 + * + * @param selectConfig 选择配置类 + * @param presenter implements of {@link IPickerPresenter} + * @return 拍照 + */ + public abstract View getCameraView(BaseSelectConfig selectConfig, IPickerPresenter presenter); + + /** + * @return 返回用于点击选中item的view + */ + public abstract View getCheckBoxView(); + + /** + * 初始化item + * + * @param imageItem 当前图片 + * @param presenter presenter + * @param selectConfig 选择器配置项 + */ + public abstract void initItem(ImageItem imageItem, IPickerPresenter presenter, BaseSelectConfig selectConfig); + + /** + * 当检测到此item不能被选中时,执行此方法 + * + * @param imageItem 当前图片 + * @param disableCode 不能选中的原因 {@link PickerItemDisableCode} + */ + public abstract void disableItem(ImageItem imageItem, int disableCode); + + /** + * 在disableItem之前调用,用于正常加载每个item + * + * @param imageItem 当前图片 + * @param isChecked 是否已经被选中 + * @param indexOfSelectedList 在已选中列表里的索引 + */ + public abstract void enableItem(ImageItem imageItem, boolean isChecked, int indexOfSelectedList, boolean isMax); + + private RecyclerView.Adapter adapter; + private int position; + + public void setAdapter(RecyclerView.Adapter adapter) { + this.adapter = adapter; + } + + public void setPosition(int position) { + this.position = position; + } + + public void notifyDataSetChanged() { + if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + + public RecyclerView.Adapter getAdapter() { + return adapter; + } + + public int getPosition() { + return position; + } + + public PickerItemView(Context context) { + super(context); + } + + public PickerItemView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PickerItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java new file mode 100644 index 0000000..bd27d2b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/PreviewControllerView.java @@ -0,0 +1,149 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.RelativeLayout; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig; +import com.remax.visualnovel.widget.imagepicker.helper.DetailImageLoadHelper; +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter; +import com.remax.visualnovel.widget.imagepicker.utils.PViewSizeUtils; +import com.remax.visualnovel.widget.imagepicker.utils.PickerFileProvider; +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig; +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + +import java.io.File; +import java.util.ArrayList; + +/** + * Time: 2019/11/13 14:39 + * Author:ypx + * Description:自定义预览页面 + */ +public abstract class PreviewControllerView extends PBaseLayout { + + /** + * 设置状态栏 + */ + public abstract void setStatusBar(); + + /** + * 初始化数据 + * + * @param selectConfig 选择配置项 + * @param presenter presenter + * @param uiConfig ui配置类 + * @param selectedList 已选中列表 + */ + public abstract void initData(BaseSelectConfig selectConfig, IPickerPresenter presenter, + PickerUiConfig uiConfig, ArrayList selectedList); + + /** + * @return 获取可以点击完成的View + */ + public abstract View getCompleteView(); + + /** + * 单击图片 + */ + public abstract void singleTap(); + + /** + * 图片切换回调 + * + * @param position 当前图片索引 + * @param imageItem 当前图片信息 + * @param totalPreviewCount 总预览数 + */ + public abstract void onPageSelected(int position, ImageItem imageItem, int totalPreviewCount); + + + public PreviewControllerView(Context context) { + super(context); + } + + public PreviewControllerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public PreviewControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * 获取预览的fragment里的布局 + * + * @param fragment 当前加载的fragment,可以使用以下方式来绑定生命周期 + *

+ * fragment.getLifecycle().addObserver(new ILifeCycleCallBack() { + * public void onResume() {} + * public void onPause() {} + * public void onDestroy() {} + * }); + *

+ * @param imageItem 当前加载imageitem + * @param presenter presenter + * @return 预览的布局 + */ + public View getItemView(Fragment fragment, final ImageItem imageItem, IPickerPresenter presenter) { + if (imageItem == null) { + return new View(fragment.getContext()); + } + + RelativeLayout layout = new RelativeLayout(getContext()); + final CropImageView imageView = new CropImageView(getContext()); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + // 启用图片缩放功能 + imageView.setBounceEnable(true); + imageView.enable(); + imageView.setShowImageRectLine(false); + imageView.setCanShowTouchLine(false); + imageView.setMaxScale(3.0f); + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + imageView.setLayoutParams(params); + layout.setLayoutParams(params); + layout.addView(imageView); + + ImageView mVideoImg = new ImageView(getContext()); + mVideoImg.setImageDrawable(getResources().getDrawable(R.mipmap.picker_icon_video)); + RelativeLayout.LayoutParams params1 = new RelativeLayout.LayoutParams(PViewSizeUtils.dp(getContext(), 80), PViewSizeUtils.dp(getContext(), 80)); + mVideoImg.setLayoutParams(params1); + params1.addRule(RelativeLayout.CENTER_IN_PARENT); + layout.addView(mVideoImg, params1); + + if (imageItem.isVideo()) { + mVideoImg.setVisibility(View.VISIBLE); + } else { + mVideoImg.setVisibility(View.GONE); + } + + imageView.setOnClickListener(v -> { + if (imageItem.isVideo()) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + Uri uri = imageItem.getUri(); + if (uri == null) { + uri = PickerFileProvider.getUriForFile((Activity) getContext(), new File(imageItem.path)); + } + intent.setDataAndType(uri, "video/*"); + getContext().startActivity(intent); + return; + } + singleTap(); + }); + DetailImageLoadHelper.displayDetailImage(false, imageView, presenter, imageItem); + return layout; + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java new file mode 100644 index 0000000..a8b6a36 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/base/SingleCropControllerView.java @@ -0,0 +1,46 @@ +package com.remax.visualnovel.widget.imagepicker.views.base; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView; + + +/** + * Time: 2019/11/13 14:39 + * Author:ypx + * Description:自定义剪裁页面 + */ +public abstract class SingleCropControllerView extends PBaseLayout { + + /** + * 设置状态栏 + */ + public abstract void setStatusBar(); + + /** + * @return 获取可以点击完成的View + */ + public abstract View getCompleteView(); + + /** + * @param cropImageView 剪裁的ImageView + * @param params params + */ + public abstract void setCropViewParams(CropImageView cropImageView, MarginLayoutParams params); + + public SingleCropControllerView(Context context) { + super(context); + } + + public SingleCropControllerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public SingleCropControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt new file mode 100644 index 0000000..0d7976c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/BottomBar.kt @@ -0,0 +1,80 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickBottomBarBinding +import com.remax.visualnovel.extension.findActivityContext +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.dialoglib.ScreenUtils +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView + +/** + * Created by HJW on 2022/10/24 + */ +class BottomBar constructor(context: Context?, private val isAlbum: Boolean) : PickerControllerView(context) { + + private var binding: PickBottomBarBinding? = null + + private var tempSelectedList: ArrayList? = null + + override fun getLayoutId(): Int { + return R.layout.pick_bottom_bar + } + + override fun initView(view: View?) { + view?.run { + binding = PickBottomBarBinding.bind(this.findViewById(R.id.bottomBar)) + binding?.also { + it.content.maxWidth = ScreenUtils.getScreenWidth() - 106.dp + setOnClick(it.checkBox, it.tips) { + val appCompatActivity = context?.findActivityContext() as? AppCompatActivity + when (this) { + it.checkBox -> { + + } + + it.tips -> { + + } + } + } + } + } + } + + override fun getViewHeight(): Int { + return if (isAlbum) 68.dp else 0 + } + + override fun getCanClickToCompleteView(): View? { + return null + } + + override fun getCanClickToIntentPreviewView(): View? { + return null + } + + override fun getCanClickToToggleFolderListView(): View? { + return null + } + + override fun setTitle(title: String?) {} + + override fun onTransitImageSet(isOpen: Boolean) {} + + override fun onImageSetSelected(imageSet: ImageSet?) {} + + override fun refreshCompleteViewState(selectedList: ArrayList?, selectConfig: BaseSelectConfig?) { + tempSelectedList = selectedList + tempSelectedList?.forEach { + it.openLock = binding?.checkBox?.isChecked == true + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt new file mode 100644 index 0000000..14ab399 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/FolderItemView.kt @@ -0,0 +1,55 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerFolderItemBinding +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter +import com.remax.visualnovel.widget.imagepicker.views.base.PickerFolderItemView + +/** + * Created by HJW on 2022/10/21 + */ +class FolderItemView constructor(context: Context?) : PickerFolderItemView(context) { + + private var binding: PickerFolderItemBinding? = null + + override fun getLayoutId(): Int { + return R.layout.picker_folder_item + } + + override fun initView(view: View?) { + view?.let { + binding = PickerFolderItemBinding.bind(this.findViewById(R.id.group)) + } + } + + override fun getItemHeight(): Int = -1 + + override fun displayCoverImage(imageSet: ImageSet?, presenter: IPickerPresenter?) { + imageSet?.also { + if (it.cover != null) { + presenter?.displayImage(binding?.cover, it.cover, binding?.cover?.measuredWidth ?: 0, true) + } else { + val imageItem = ImageItem() + imageItem.path = it.coverPath + imageItem.setUriPath(it.coverPath) + presenter?.displayImage(binding?.cover, imageItem, binding?.cover?.measuredWidth ?: 0, true) + } + } + } + + override fun loadItem(imageSet: ImageSet?) { + binding?.run { + imageSet?.let { + name.text = it.name + size.text = it.count.toString() + indicator.isVisible = it.isSelected + } + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt new file mode 100644 index 0000000..3542a8a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/ItemView.kt @@ -0,0 +1,100 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerImageGridItemBinding +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter +import com.remax.visualnovel.widget.imagepicker.views.base.PickerItemView +import com.remax.visualnovel.widget.imagepicker.widget.ShowTypeImageView + +/** + * Created by HJW on 2022/10/21 + */ +class ItemView constructor(context: Context?) : PickerItemView(context) { + + private var selectConfig: BaseSelectConfig? = null + + private var binding: PickerImageGridItemBinding? = null + + override fun getLayoutId(): Int { + return R.layout.picker_image_grid_item + } + + override fun initView(view: View?) { + view?.run { + binding = PickerImageGridItemBinding.bind(this.findViewById(R.id.group)) + } + } + + override fun getCameraView(selectConfig: BaseSelectConfig?, presenter: IPickerPresenter?): View { + return LayoutInflater.from(context).inflate(R.layout.picker_item_camera, null, false) + } + + override fun getCheckBoxView(): View? { + return binding?.mCheckBoxPanel + } + + override fun initItem(imageItem: ImageItem?, presenter: IPickerPresenter?, selectConfig: BaseSelectConfig?) { + this.selectConfig = selectConfig + presenter?.displayImage(binding?.mImageView, imageItem, binding?.mImageView?.width ?: 0, true) + } + + override fun disableItem(imageItem: ImageItem?, disableCode: Int) { + //默认开启校验是否超过最大数,当超过最大选择数量时, + if (disableCode == PickerItemDisableCode.DISABLE_OVER_MAX_COUNT) { + return + } + binding?.mCheckBox?.isVisible = false + binding?.vMasker?.isVisible = true + } + + @SuppressLint("SetTextI18n") + override fun enableItem(imageItem: ImageItem?, isChecked: Boolean, indexOfSelectedList: Int, isMax: Boolean) { + binding?.run { + /*imageItem?.let { + if (it.isVideo) { + PickerImageGridItemBinding.mVideoLayout.isVisible = true + PickerImageGridItemBinding.mVideoTime.text = imageItem.getDurationFormat() + PickerImageGridItemBinding.mImageView.setType(ShowTypeImageView.TYPE_NONE) + } else { + PickerImageGridItemBinding.mVideoLayout.isVisible = false + PickerImageGridItemBinding.mImageView.setTypeFromImage(imageItem) + } + + + PickerImageGridItemBinding.mCheckBox.isVisible = true + PickerImageGridItemBinding.mCheckBoxPanel.isVisible = true + + val isVideoSinglePickAndAutoComplete = imageItem.isVideo && selectConfig!!.isVideoSinglePickAndAutoComplete + if (isVideoSinglePickAndAutoComplete || selectConfig?.isSinglePickAutoComplete == true && (selectConfig?.maxCount ?: 0) <= 1) { + PickerImageGridItemBinding.mCheckBox.isVisible = false + PickerImageGridItemBinding.mCheckBoxPanel.isVisible = false + } + + PickerImageGridItemBinding.mCheckBox.viewChecked(isChecked) + PickerImageGridItemBinding.mCheckBox.text = if (isChecked) "${indexOfSelectedList + 1}" else null + + PickerImageGridItemBinding.singleCheckBox.isVisible = false + + + + if (isMax) { + PickerImageGridItemBinding.singleCheckBox.isVisible = indexOfSelectedList == 0 + PickerImageGridItemBinding.vMasker.isVisible = !isChecked + } else { + PickerImageGridItemBinding.vMasker.isVisible = false + } + }*/ + + } + + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt new file mode 100644 index 0000000..6740415 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/PreviewControllerView.kt @@ -0,0 +1,185 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import android.view.animation.AnimationUtils +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerWxPreviewBottombarBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.toast +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.itemdecoration.HorizontalItemDecoration +import com.remax.visualnovel.widget.imagepicker.adapter.MultiPreviewAdapter +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.PickerItemDisableCode +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.helper.recyclerviewitemhelper.SimpleItemTouchHelperCallback +import com.remax.visualnovel.widget.imagepicker.presenter.IPickerPresenter +import com.remax.visualnovel.widget.imagepicker.views.PickerUiConfig +import com.remax.visualnovel.widget.imagepicker.views.base.PreviewControllerView + +import java.io.File +import java.lang.ref.WeakReference + +/** + * Created by HJW on 2022/10/21 + */ +class PreviewControllerView constructor(context: Context?) : PreviewControllerView(context) { + + private var binding: PickerWxPreviewBottombarBinding? = null + + private var previewAdapter: MultiPreviewAdapter? = null + private var presenter: IPickerPresenter? = null + private var selectConfig: BaseSelectConfig? = null + private var uiConfig: PickerUiConfig? = null + private var selectedList: ArrayList? = null + private var isShowBottomBar = true + private var isShowCompleteBtn = true + + override fun getLayoutId(): Int = R.layout.picker_wx_preview_bottombar + + override fun initView(view: View?) { + view?.run { + binding = PickerWxPreviewBottombarBinding.bind(this.findViewById(R.id.group)) + binding?.let { + setOnClick(it.mTitleContainer.navBack) { + onBackPressed() + } + } + } + } + + override fun setStatusBar() { + + } + + override fun initData(selectConfig: BaseSelectConfig?, presenter: IPickerPresenter?, uiConfig: PickerUiConfig?, selectedList: ArrayList?) { + this.selectConfig = selectConfig + this.presenter = presenter + this.selectedList = selectedList + this.uiConfig = uiConfig + initUI() + initPreviewList() + binding?.run { + bottomBar.isVisible = isShowBottomBar + mPreviewRecyclerView.isVisible = isShowBottomBar + + mTitleContainer.rightConfirmBtn.isVisible = isShowCompleteBtn + } + } + + private var currentImageItem: ImageItem? = null + private var currentPosition = 0 + private var currentIsChecked = false + + private fun initUI() { + binding?.run { + setOnClick(mSelectCheckBox) { + currentIsChecked = !currentIsChecked + checkButton.viewChecked(currentIsChecked) + if (currentIsChecked) { + if (!File(currentImageItem?.path.toString()).exists()) { + checkButton.viewChecked(false) + context.toast(context.getString(R.string.file_not_found_hint)) + } else { + val disableCode = PickerItemDisableCode.getItemDisableCode( + currentImageItem, selectConfig, selectedList, + selectedList?.contains(currentImageItem) == true + ) + + if (disableCode != PickerItemDisableCode.NORMAL) { + val message = PickerItemDisableCode.getMessageFormCode(context, disableCode, presenter, selectConfig) + if (message.isNotEmpty()) { + presenter?.tip(WeakReference(context).get(), message) + } + checkButton.viewChecked(false) + return@setOnClick + } + if (selectedList?.contains(currentImageItem) == false) { + currentImageItem?.let { selectedList?.add(it) } + } + } + } else { + selectedList?.remove(currentImageItem) + } + notifyPreviewList(currentImageItem) + mTitleContainer.rightConfirmBtn.isEnabled = !selectedList.isNullOrEmpty() + } + } + } + + private fun initPreviewList() { + binding?.run { + mPreviewRecyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + val space = 12.dp + mPreviewRecyclerView.addItemDecoration(HorizontalItemDecoration(space, space * 2, space * 2)) + previewAdapter = MultiPreviewAdapter(selectedList, presenter) + mPreviewRecyclerView.adapter = previewAdapter + val callback = SimpleItemTouchHelperCallback(previewAdapter) + val mItemTouchHelper = ItemTouchHelper(callback) + mItemTouchHelper.attachToRecyclerView(mPreviewRecyclerView) + } + } + + override fun getCompleteView(): View? { + return binding?.mTitleContainer?.rightConfirmBtn + } + + override fun singleTap() { + binding?.run { + if (mTitleContainer.navRoot.isVisible) { + mTitleContainer.navRoot.animation = AnimationUtils.loadAnimation(context, R.anim.picker_top_out) + mTitleContainer.navRoot.isVisible = false + if (isShowBottomBar) { + bottomBar.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_out) + bottomBar.isVisible = false + mPreviewRecyclerView.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_out) + mPreviewRecyclerView.isVisible = false + } + } else { + mTitleContainer.navRoot.animation = AnimationUtils.loadAnimation(context, R.anim.picker_top_in) + mTitleContainer.navRoot.isVisible = true + if (isShowBottomBar) { + bottomBar.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_in) + bottomBar.isVisible = true + mPreviewRecyclerView.animation = AnimationUtils.loadAnimation(context, R.anim.picker_fade_in) + mPreviewRecyclerView.isVisible = true + } + } + } + } + + /** + * 刷新预览编辑列表 + * + * @param imageItem 当前预览的图片 + */ + private fun notifyPreviewList(imageItem: ImageItem?) { + previewAdapter?.setPreviewImageItem(imageItem) + if (selectedList?.contains(imageItem) == true) { + binding?.mPreviewRecyclerView?.smoothScrollToPosition(selectedList?.indexOf(imageItem) ?: 0) + } + } + + override fun onPageSelected(position: Int, imageItem: ImageItem?, totalPreviewCount: Int) { + binding?.run { + currentPosition = position + currentImageItem = imageItem + mTitleContainer.tvTitle.text = String.format("%d/%d", position + 1, totalPreviewCount) + currentIsChecked = selectedList?.contains(imageItem) == true + checkButton.viewChecked(currentIsChecked) + notifyPreviewList(imageItem) + + if ((selectConfig?.maxCount ?: 0) <= 1 && selectConfig?.isSinglePickAutoComplete == true) { + mTitleContainer.rightConfirmBtn.isVisible = false + } else { + mTitleContainer.rightConfirmBtn.isVisible = true + mTitleContainer.rightConfirmBtn.isEnabled = !selectedList.isNullOrEmpty() + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt new file mode 100644 index 0000000..0d68984 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/SingleCropControllerView.kt @@ -0,0 +1,50 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.LayoutEpalCropBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.imagepicker.widget.cropimage.CropImageView +import com.remax.visualnovel.widget.imagepicker.views.base.SingleCropControllerView + +/** + * Created by HJW on 2022/10/24 + */ +class SingleCropControllerView(context: Context?) : SingleCropControllerView(context) { + + private var binding: LayoutEpalCropBinding? = null + private var cropImageView: CropImageView? = null + + override fun getLayoutId(): Int { + return R.layout.layout_epal_crop + } + + override fun initView(view: View?) { + view?.run { + binding = LayoutEpalCropBinding.bind(this.findViewById(R.id.group)) + binding?.also { + setOnClick(it.cropBackLayout) { + when (this) { + it.cropBackLayout -> { + onBackPressed() + } + } + + } + } + } + } + + override fun setStatusBar() { + + } + + override fun getCompleteView(): View? { + return binding?.cropSaveLayout + } + + override fun setCropViewParams(cropImageView: CropImageView?, params: MarginLayoutParams?) { + this.cropImageView = cropImageView + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt new file mode 100644 index 0000000..7dcbedc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/views/wrapper/TitleBar.kt @@ -0,0 +1,94 @@ +package com.remax.visualnovel.widget.imagepicker.views.wrapper + +import android.content.Context +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.PickerRedbookTitlebarBinding +import com.remax.visualnovel.extension.navRotationOpen +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem +import com.remax.visualnovel.widget.imagepicker.bean.ImageSet +import com.remax.visualnovel.widget.imagepicker.bean.selectconfig.BaseSelectConfig +import com.remax.visualnovel.widget.imagepicker.views.base.PickerControllerView +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.handleUIToken + +/** + * Created by HJW on 2022/10/24 + */ +class TitleBar constructor(context: Context?) : PickerControllerView(context) { + + private var binding: PickerRedbookTitlebarBinding? = null + + override fun getLayoutId(): Int { + return R.layout.picker_redbook_titlebar + } + + override fun initView(view: View?) { + view?.run { + binding = PickerRedbookTitlebarBinding.bind(this.findViewById(R.id.titleBar)) + binding?.also { + val bgColor = context.handleUIToken(R.string.color_background_specialmap)?.color ?: 0 + it.mTitleContainer.navBg.setBackgroundColor(bgColor) + it.mTitleContainer.navBack.setText(R.string.icon_close) + it.mTitleContainer.tvTitleArrow.isVisible = true + setOnClick(it.mTitleContainer.navBack) { + onBackPressed() + } + } + } + } + + override fun getViewHeight(): Int { + return context.resources.getDimensionPixelSize(R.dimen.nav_height) + } + + override fun getCanClickToCompleteView(): View? { + return binding?.mTitleContainer?.rightConfirmBtn + } + + override fun getCanClickToIntentPreviewView(): View? { + return null + } + + override fun getCanClickToToggleFolderListView(): View? { + return binding?.mTitleContainer?.tvTitleLayout + } + + override fun setTitle(title: String?) { + binding?.mTitleContainer?.also { + it.tvTitle.text = title + } + } + + override fun onTransitImageSet(isOpen: Boolean) { + binding?.run { + mTitleContainer.tvTitleArrow.navRotationOpen(isOpen) + mTitleContainer.tvTitle.changeTextColor { + textUIColorToken = context.getString(if (isOpen) R.string.color_primary_variant_normal else R.string.color_txt_primary_normal) + } + mTitleContainer.tvTitleArrow.changeTextColor { + textUIColorToken = context.getString(if (isOpen) R.string.color_primary_variant_normal else R.string.color_txt_secondary_normal) + } + } + } + + override fun onImageSetSelected(imageSet: ImageSet?) { + binding?.mTitleContainer?.also { + it.tvTitle.text = imageSet?.name + } + } + + override fun refreshCompleteViewState(selectedList: ArrayList?, selectConfig: BaseSelectConfig?) { + binding?.also { + if ((selectConfig?.maxCount ?: 0) <= 1 && selectConfig?.isSinglePickAutoComplete == true) { + it.mTitleContainer.rightConfirmBtn.isVisible = false + } else { + it.mTitleContainer.rightConfirmBtn.isVisible = true + it.mTitleContainer.rightConfirmBtn.isEnabled = !selectedList.isNullOrEmpty() + } + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java new file mode 100644 index 0000000..5e98996 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/ShowTypeImageView.java @@ -0,0 +1,184 @@ +package com.remax.visualnovel.widget.imagepicker.widget; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.ImageView; + +import com.remax.visualnovel.R; +import com.remax.visualnovel.widget.imagepicker.bean.ImageItem; + +/** + * 可以根据宽高和类型动态显示长图和gif图标签 + *

+ * yangpeixing on 2017/12/7 16:40 + */ +@SuppressLint("AppCompatCustomView") +public class ShowTypeImageView extends ImageView { + public static final int TYPE_GIF = 1;//gif图片 + public static final int TYPE_LONG = 2;//长图 + public static final int TYPE_NONE = 3;//正常图 + public static final int TYPE_VIDEO = 5;//视频 + public static final int TYPE_IMAGECOUNT = 4;//数量 + + protected int imageType = TYPE_NONE; + + private String imageCountTip = ""; + + private boolean isSelect = false; + + public ShowTypeImageView(Context context) { + super(context); + init(); + } + + public ShowTypeImageView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ShowTypeImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public void setType(int type) { + this.imageType = type; + invalidate(); + } + + public void setSelect(boolean isSelect, int selectColor) { + this.isSelect = isSelect; + mSelectPaint.setColor(selectColor); + invalidate(); + } + + private Paint mCirclePaint; + private Paint mMaskPaint; + private Paint mBitmapPaint; + private Paint mTextPaint; + private RectF rectF; + private Paint mSelectPaint; + private Bitmap videoBitmap; + + private void init() { + mCirclePaint = new Paint(); + mCirclePaint.setAntiAlias(true); + mCirclePaint.setColor(Color.parseColor("#ffffff")); + mCirclePaint.setAlpha(200); + + mMaskPaint = new Paint(); + mMaskPaint.setAntiAlias(true); + mMaskPaint.setColor(Color.parseColor("#40000000")); + + mBitmapPaint = new Paint(); + mBitmapPaint.setAntiAlias(true); + + mTextPaint = new Paint(); + mTextPaint.setAntiAlias(true); + mTextPaint.setColor(Color.parseColor("#90000000")); + mTextPaint.setTextSize(sp(12)); + mTextPaint.setTypeface(Typeface.DEFAULT_BOLD); + rectF = new RectF(); + + mSelectPaint = new Paint(); + mSelectPaint.setAntiAlias(true); + mSelectPaint.setStrokeWidth(dp(4)); + mSelectPaint.setStyle(Paint.Style.STROKE); + + try { + videoBitmap = ((BitmapDrawable) getResources().getDrawable(R.mipmap.picker_item_video)).getBitmap(); + } catch (Exception ignored) { + + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (isSelect) { + canvas.drawRect(0, 0, getWidth(), getHeight(), mSelectPaint); + } + + if (imageType == TYPE_NONE) { + return; + } + int width = getWidth(); + int height = getHeight(); + + switch (imageType) { + case TYPE_VIDEO: + if (videoBitmap != null) { + canvas.drawRect(0, 0, width, height, mMaskPaint); + canvas.drawBitmap(videoBitmap, (width - videoBitmap.getWidth()) >> 1, + (height - videoBitmap.getHeight()) >> 1, mBitmapPaint); + } + + break; + case TYPE_GIF: + canvas.drawCircle(width >> 1, height >> 1, width * 0.18f, mCirclePaint); + canvas.drawText("GIF", (width >> 1) - dp(10), (height >> 1) + dp(5), mTextPaint); + break; + + case TYPE_LONG: + rectF.left = width - dp(30); + rectF.top = height - dp(20); + rectF.right = width + dp(3); + rectF.bottom = height; + canvas.drawRoundRect(rectF, dp(3), dp(3), mCirclePaint); + canvas.drawText("长图", width - dp(27), height - dp(6), mTextPaint); + break; + + case TYPE_IMAGECOUNT: + rectF.left = width - dp(30); + rectF.top = height - dp(20); + rectF.right = width + dp(3); + rectF.bottom = height; + canvas.drawRoundRect(rectF, dp(3), dp(3), mCirclePaint); + canvas.drawText(imageCountTip, width - dp(27), height - dp(6), mTextPaint); + break; + } + + + } + + public int sp(float spValue) { + final float fontScale = getContext().getResources().getDisplayMetrics().scaledDensity; + return (int) (spValue * fontScale + 0.5f); + } + + public void setImageCountTip(String imageCountTip) { + this.imageCountTip = imageCountTip; + this.imageType = TYPE_IMAGECOUNT; + invalidate(); + } + + + public void setTypeFromImage(ImageItem imageItem) { + if (imageType == TYPE_IMAGECOUNT) { + return; + } + if (imageItem.isVideo()) { + setType(TYPE_VIDEO); + } else if (imageItem.isGif()) { + setType(TYPE_GIF); + } else if (imageItem.isLongImage()) { + setType(TYPE_LONG); + } else { + setType(TYPE_NONE); + } + } + + public int dp(float dpVal) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + dpVal, this.getResources().getDisplayMetrics()); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java new file mode 100644 index 0000000..abd3130 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/TouchRecyclerView.java @@ -0,0 +1,106 @@ +package com.remax.visualnovel.widget.imagepicker.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Description: 可监听滑动的recyclerView + *

+ * Author: peixing.yang + * Date: 2019/2/22 + */ +public class TouchRecyclerView extends RecyclerView { + public TouchRecyclerView(@NonNull Context context) { + super(context); + } + + public TouchRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TouchRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + float firstY = 0; + float lastY = 0; + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + lastY = ev.getY(); + break; + case MotionEvent.ACTION_MOVE: + float y = ev.getY(); + + if (y < lastY) { + if (isTouchPointInView(touchView, ev.getX(), ev.getY())) { + if (dragScrollListener != null) { + int distance = (int) ((y - lastY)); + int defaultDis = (int) (lastY - getPaddingTop()); + dragScrollListener.onScrollOverTop(Math.abs(distance + defaultDis)); + } + } + } else { + if (dragScrollListener != null) { + dragScrollListener.onScrollDown((int) (y - lastY)); + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + lastY = 0; + if (dragScrollListener != null) { + dragScrollListener.onScrollUp(); + } + break; + } + return super.dispatchTouchEvent(ev); + } + + private View touchView; + + public void setTouchView(View view) { + this.touchView = view; + } + + //(x,y)是否在view的区域内 + private boolean isTouchPointInView(View view, float x, float y) { + if (view == null) { + return false; + } + int[] location = new int[2]; + view.getLocationOnScreen(location); + int left = location[0]; + int top = location[1]; + int right = left + view.getMeasuredWidth(); + int bottom = top + view.getMeasuredHeight(); + //view.isClickable() && + if (y >= top && y <= bottom && x >= left + && x <= right) { + return true; + } + return false; + } + + private onDragScrollListener dragScrollListener; + + public void setDragScrollListener(onDragScrollListener dragScrollListener) { + this.dragScrollListener = dragScrollListener; + } + + public interface onDragScrollListener { + void onScrollOverTop(int distance); + + void onScrollDown(int distance); + + void onScrollUp(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java new file mode 100644 index 0000000..cf76273 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/CropImageView.java @@ -0,0 +1,1596 @@ +package com.remax.visualnovel.widget.imagepicker.widget.cropimage; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PointF; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.AnimationDrawable; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.ImageView; +import android.widget.OverScroller; +import android.widget.Scroller; + +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.remax.visualnovel.widget.imagepicker.utils.PBitmapUtils; + + +/** + * Description: 剪裁ImageView + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +@SuppressLint("AppCompatCustomView") +public class CropImageView extends ImageView { + private final static int MIN_ROTATE = 35; + private final static int ANIM_DURING = 340; + private final static float MAX_SCALE = 2.5f; + private static int loadMaxSize = 0; + + private int mMinRotate; + private int mAnimDuring; + private float mMaxScale; + + private int MAX_FLING_OVER_SCROLL = 0; + private int MAX_OVER_RESISTANCE = 0; + + private Matrix mBaseMatrix = new Matrix(); + private Matrix mAnimMatrix = new Matrix(); + private Matrix mSynthesisMatrix = new Matrix(); + private Matrix mTmpMatrix = new Matrix(); + + private RotateGestureDetector mRotateDetector; + private GestureDetector mDetector; + private ScaleGestureDetector mScaleDetector; + private OnClickListener mClickListener; + + private ScaleType mScaleType = ScaleType.CENTER_INSIDE; + + private boolean hasMultiTouch; + private boolean hasDrawable; + private boolean isKnowSize; + private boolean hasOverTranslate; + private boolean isEnable = false; + private boolean isRotateEnable = false; + // 当前是否处于放大状态 + private boolean isZoomUp; + private boolean canRotate; + + private boolean imgLargeWidth; + private boolean imgLargeHeight; + + private float mRotateFlag; + private float mDegrees; + private float mScale = 1.0f; + private int mTranslateX; + private int mTranslateY; + + private RectF mCropRect = new RectF(); + private RectF mBaseRect = new RectF(); + private RectF mImgRect = new RectF(); + private RectF mTmpRect = new RectF(); + private RectF mCommonRect = new RectF(); + + private PointF mScreenCenter = new PointF(); + private PointF mScaleCenter = new PointF(); + private PointF mRotateCenter = new PointF(); + + private Paint linePaint; + + private Transform mTranslate = new Transform(); + + private RectF mClip; + private Runnable mCompleteCallBack; + + private OnLongClickListener mLongClick; + + private boolean isShowCropRect = true; + + public CropImageView(Context context) { + super(context); + init(); + } + + public CropImageView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CropImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + super.setScaleType(ScaleType.MATRIX); + if (mScaleType == null) mScaleType = ScaleType.CENTER_CROP; + mRotateDetector = new RotateGestureDetector(mRotateListener); + mDetector = new GestureDetector(getContext(), mGestureListener); + mScaleDetector = new ScaleGestureDetector(getContext(), mScaleListener); + float density = getResources().getDisplayMetrics().density; + MAX_FLING_OVER_SCROLL = (int) (density * 30); + MAX_OVER_RESISTANCE = (int) (density * 140); + + mMinRotate = MIN_ROTATE; + mAnimDuring = ANIM_DURING; + mMaxScale = MAX_SCALE; + + initCropLineRect(); + initCropRect(); + } + + @Override + public void setOnClickListener(OnClickListener l) { + super.setOnClickListener(l); + mClickListener = l; + } + + @Override + public void setScaleType(ScaleType scaleType) { + if (scaleType == ScaleType.MATRIX) return; + + if (scaleType != mScaleType) { + mScaleType = scaleType; + initBase(); + } + } + + public ScaleType getNewScaleType() { + return mScaleType; + } + + @Override + public void setOnLongClickListener(OnLongClickListener l) { + mLongClick = l; + } + + + /** + * 设置最大可以缩放的倍数 + */ + public void setMaxScale(float maxScale) { + mMaxScale = maxScale; + } + + /** + * 启用缩放功能 + */ + public void enable() { + isEnable = true; + } + + @Override + public void setImageResource(int resId) { + Drawable drawable = null; + try { + drawable = getResources().getDrawable(resId); + } catch (Exception ignored) { + } + + setImageDrawable(drawable); + } + + private Bitmap originalBitmap; + + public Bitmap getOriginalBitmap() { + return originalBitmap; + } + + @Override + public void setImageBitmap(Bitmap bm) { + if (bm == null || bm.getWidth() == 0 || bm.getHeight() == 0) { + return; + } + + originalBitmap = bm; + if (loadMaxSize == 0) { + loadMaxSize = Math.max(bm.getWidth(), bm.getHeight()); + } + + float ratio = bm.getWidth() * 1.00f / bm.getHeight() * 1.00f; + if (bm.getWidth() > loadMaxSize) { + bm = Bitmap.createScaledBitmap(bm, loadMaxSize, (int) (loadMaxSize / ratio), false); + } + + if (bm.getHeight() > loadMaxSize) { + bm = Bitmap.createScaledBitmap(bm, (int) (loadMaxSize * ratio), loadMaxSize, false); + } + + super.setImageBitmap(bm); + } + + @Override + public void setImageDrawable(Drawable drawable) { + super.setImageDrawable(drawable); + if (drawable == null) { + hasDrawable = false; + return; + } + if (!hasSize(drawable)) { + return; + } + + hasDrawable = true; + if (originalBitmap == null) { + if (drawable instanceof BitmapDrawable) { + originalBitmap = ((BitmapDrawable) drawable).getBitmap(); + } else if (drawable instanceof AnimationDrawable) { + AnimationDrawable drawable1 = (AnimationDrawable) drawable; + Drawable drawable2 = drawable1.getFrame(0); + if (drawable2 instanceof BitmapDrawable) { + originalBitmap = ((BitmapDrawable) drawable2).getBitmap(); + } + } else if (drawable instanceof GifDrawable) { + originalBitmap = ((GifDrawable) drawable).getFirstFrame(); + } + } + + if (onImageLoadListener != null) { + onImageLoadListener.onImageLoaded(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + onImageLoadListener = null; + } + + if (restoreInfo != null) { + mScaleType = restoreInfo.getScaleType(); + mCropRect = restoreInfo.mWidgetRect; + aspectX = (int) restoreInfo.mCropX; + aspectY = (int) restoreInfo.mCropY; + initBase(); + post(new Runnable() { + @Override + public void run() { + restoreCrop(); + } + }); + } else { + initBase(); + } + } + + private Info restoreInfo; + + public void setRestoreInfo(Info restoreInfo) { + this.restoreInfo = restoreInfo; + } + + /** + * 恢复状态 + */ + private void restoreCrop() { + Info info = restoreInfo; + + mTranslateX = 0; + mTranslateY = 0; + + if (info == null || info.mImgRect == null) { + return; + } + + float tcx = info.mImgRect.left + info.mImgRect.width() / 2; + float tcy = info.mImgRect.top + info.mImgRect.height() / 2; + + mScaleCenter.set(mImgRect.left + mImgRect.width() / 2, mImgRect.top + mImgRect.height() / 2); + mRotateCenter.set(mScaleCenter); + + // 将图片旋转回正常位置,用以计算 + mAnimMatrix.postRotate(-mDegrees, mScaleCenter.x, mScaleCenter.y); + mAnimMatrix.mapRect(mImgRect, mBaseRect); + + // 缩放 + float scaleX = info.mImgRect.width() / mBaseRect.width(); + float scaleY = info.mImgRect.height() / mBaseRect.height(); + float scale = scaleX > scaleY ? scaleX : scaleY; + + mAnimMatrix.postRotate(mDegrees, mScaleCenter.x, mScaleCenter.y); + mAnimMatrix.mapRect(mImgRect, mBaseRect); + + mDegrees = mDegrees % 360; + + mTranslate.withTranslate(0, 0, (int) (tcx - mScaleCenter.x), (int) (tcy - mScaleCenter.y)); + mTranslate.withScale(mScale, scale); + mTranslate.withRotate((int) mDegrees, (int) info.mDegrees, mAnimDuring * 2 / 3); + mTranslate.start(); + + restoreInfo = null; + } + + onImageLoadListener onImageLoadListener; + + public interface onImageLoadListener { + void onImageLoaded(float w, float h); + } + + public void setOnImageLoadListener(onImageLoadListener onImageLoadListener) { + this.onImageLoadListener = onImageLoadListener; + } + + private boolean hasSize(Drawable d) { + return (d.getIntrinsicHeight() > 0 && d.getIntrinsicWidth() > 0) + || (d.getMinimumWidth() > 0 && d.getMinimumHeight() > 0) + || (d.getBounds().width() > 0 && d.getBounds().height() > 0); + } + + private static int getDrawableWidth(Drawable d) { + int width = d.getIntrinsicWidth(); + if (width <= 0) width = d.getMinimumWidth(); + if (width <= 0) width = d.getBounds().width(); + return width; + } + + private static int getDrawableHeight(Drawable d) { + int height = d.getIntrinsicHeight(); + if (height <= 0) height = d.getMinimumHeight(); + if (height <= 0) height = d.getBounds().height(); + return height; + } + + float baseScale; + + public void initBase() { + if (!hasDrawable) return; + if (!isKnowSize) return; + + mBaseMatrix.reset(); + mAnimMatrix.reset(); + isZoomUp = false; + + Drawable img = getDrawable(); + + int w = getWidth(); + int h = getHeight(); + int drawableWidth = getDrawableWidth(img); + int drawableHeight = getDrawableHeight(img); + + mBaseRect.set(0, 0, drawableWidth, drawableHeight); + + // 以图片中心点居中位移 + int tx = (w - drawableWidth) / 2; + int ty = (h - drawableHeight) / 2; + + float sx = 1; + float sy = 1; + + // 缩放,默认不超过屏幕大小 + if (drawableWidth > w) { + sx = (float) w / drawableWidth; + } + + if (drawableHeight > h) { + sy = (float) h / drawableHeight; + } + + baseScale = Math.min(sx, sy); + + mBaseMatrix.reset(); + mBaseMatrix.postTranslate(tx, ty); + mBaseMatrix.postScale(baseScale, baseScale, mScreenCenter.x, mScreenCenter.y); + mBaseMatrix.mapRect(mBaseRect); + + mScaleCenter.set(mScreenCenter); + mRotateCenter.set(mScaleCenter); + + executeTranslate(); + + switch (mScaleType) { + case CENTER: + initCenter(); + break; + case CENTER_CROP: + initCenterCrop(); + break; + case CENTER_INSIDE: + initCenterInside(); + break; + case FIT_CENTER: + initFitCenter(); + break; + case FIT_START: + initFitStart(); + break; + case FIT_END: + initFitEnd(); + break; + case FIT_XY: + initFitXY(); + break; + } + } + + private void initCenter() { + mAnimMatrix.postScale(1, 1, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + } + + private void initCenterCrop() { + float widthScale = mCropRect.width() / mImgRect.width(); + float heightScale = mCropRect.height() / mImgRect.height(); + mScale = Math.max(widthScale, heightScale); + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + } + + private void initCenterInside() { + //控件大于图片,即可完全显示图片,相当于Center,反之,相当于FitCenter + if (mCropRect.width() > mImgRect.width()) { + initCenter(); + } else { + initFitCenter(); + } + float widthScale = mCropRect.width() / mImgRect.width(); + if (widthScale > mMaxScale) { + mMaxScale = widthScale; + } + } + + private void initFitCenter() { + float widthScale = mCropRect.width() / mImgRect.width(); + float heightScale = mCropRect.height() / mImgRect.height(); + mScale = Math.min(widthScale, heightScale); + mAnimMatrix.postScale(mScale, mScale, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + + if (widthScale > mMaxScale) { + mMaxScale = widthScale; + } + } + + private void initFitStart() { + initFitCenter(); + float ty = -mImgRect.top; + mAnimMatrix.postTranslate(0, ty); + executeTranslate(); + resetBase(); + mTranslateY += ty; + } + + private void initFitEnd() { + initFitCenter(); + float ty = (mCropRect.bottom - mImgRect.bottom); + mTranslateY += ty; + mAnimMatrix.postTranslate(0, ty); + executeTranslate(); + resetBase(); + } + + private void initFitXY() { + float widthScale = mCropRect.width() / mImgRect.width(); + float heightScale = mCropRect.height() / mImgRect.height(); + mAnimMatrix.postScale(widthScale, heightScale, mScreenCenter.x, mScreenCenter.y); + executeTranslate(); + resetBase(); + } + + private void resetBase() { + Drawable img = getDrawable(); + mBaseRect.set(0, 0, getDrawableWidth(img), getDrawableHeight(img)); + mBaseMatrix.set(mSynthesisMatrix); + mBaseMatrix.mapRect(mBaseRect); + mScale = 1; + mTranslateX = 0; + mTranslateY = 0; + mAnimMatrix.reset(); + } + + private void executeTranslate() { + mSynthesisMatrix.set(mBaseMatrix); + mSynthesisMatrix.postConcat(mAnimMatrix); + setImageMatrix(mSynthesisMatrix); + mAnimMatrix.mapRect(mImgRect, mBaseRect); + imgLargeWidth = mImgRect.width() >= mCropRect.width(); + imgLargeHeight = mImgRect.height() >= mCropRect.height(); + } + + private int aspectX = -1, aspectY = -1; + private int cropMargin = 0; + + public void setCropRatio(int aspectX, int aspectY) { + this.aspectX = aspectX; + this.aspectY = aspectY; + if (cropAnim != null && cropAnim.isRunning()) { + cropAnim.cancel(); + } + if (aspectX <= 0 || aspectY <= 0) { + mCropRect.set(0, 0, getWidth(), getHeight()); + mScaleType = ScaleType.CENTER_INSIDE; + initBase(); + invalidate(); + return; + } + + mScaleType = ScaleType.CENTER_CROP; + resetCropSize(getWidth(), getHeight()); + } + + public boolean isEditing() { + return isShowLine; + } + + public void setCropMargin(int cropMargin) { + this.cropMargin = cropMargin; + } + + public int getCropWidth() { + return (int) mCropRect.width(); + } + + public int getCropHeight() { + return (int) mCropRect.height(); + } + + private void resetCropSize(int w, int h) { + float left = 0, top = 0, right = w, bottom = h; + if (aspectY != -1 && aspectX != -1) { + float cropRatio = aspectX * 1.00f / aspectY; + float viewRatio = w * 1.00f / h; + if (h > w) {//view的高>宽 + float top1 = (h - (w - cropMargin * 2) * 1.00f / cropRatio) * 1.00f / 2; + if (cropRatio >= 1) {//宽比例剪裁 + left = cropMargin; + right = w - left; + top = top1; + bottom = h - top; + } else if (cropRatio < 1) {//高比例剪裁 + if (cropRatio > viewRatio) {//剪裁比例大于view宽高比,说明以宽充满,剪裁的高肯定不会超出view的高 + left = cropMargin; + right = w - left; + top = top1; + bottom = h - top; + } else {//剪裁比例小于view宽高比,说明以高充满,宽度肯定不会超过view的宽度 + top = cropMargin; + bottom = h - top; + left = (w - (h - cropMargin * 2) * cropRatio) / 2; + right = w - left; + + } + } + } + anim(left, top, right, bottom); + } else { + mCropRect.set(left, top, right, bottom); + initBase(); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + isKnowSize = true; + mScreenCenter.set(w / 2.0f, h / 2.0f); + resetCropSize(w, h); + setImageDrawable(getDrawable()); + } + + public void setRotateEnable(boolean rotateEnable) { + isRotateEnable = rotateEnable; + } + + private boolean isShowLine = false; + private Paint cropRectPaint; + private Paint maskPaint; + private Paint cropStrokePaint; + + private void initCropRect() { + cropRectPaint = new Paint(); + cropRectPaint.setStrokeWidth(dp(2f)); + cropRectPaint.setColor(Color.WHITE); + cropRectPaint.setAntiAlias(true); + cropRectPaint.setStyle(Paint.Style.STROKE); + cropRectPaint.setDither(true); + initMaskPaint(); + } + + private void initCropLineRect() { + linePaint = new Paint(); + linePaint.setColor(Color.WHITE); + linePaint.setAntiAlias(true); + linePaint.setStrokeWidth(dp(0.5f)); + linePaint.setStyle(Paint.Style.FILL); + + cropStrokePaint = new Paint(); + cropStrokePaint.setColor(Color.WHITE); + cropStrokePaint.setAntiAlias(true); + cropStrokePaint.setStrokeCap(Paint.Cap.ROUND); + cropStrokePaint.setStrokeWidth(dp(4)); + cropStrokePaint.setStyle(Paint.Style.STROKE); + } + + private void initMaskPaint() { + maskPaint = new Paint(); + maskPaint.setColor(Color.parseColor("#a0000000")); + maskPaint.setAntiAlias(true); + maskPaint.setStyle(Paint.Style.FILL); + } + + private Rect viewDrawingRect = new Rect(); + private Path path = new Path(); + + private void drawStrokeLine(Canvas canvas) { + int lineWidth = dp(30); + float x = mCropRect.left; + float y = mCropRect.top + dp(1); + float w = mCropRect.width(); + float h = mCropRect.height() - dp(2); + canvas.drawLine(x, y, lineWidth + x, y, cropStrokePaint); + canvas.drawLine(x, y, x, y + lineWidth, cropStrokePaint); + canvas.drawLine(x, y + h, x, y + h - lineWidth, cropStrokePaint); + canvas.drawLine(x, y + h, x + lineWidth, y + h, cropStrokePaint); + canvas.drawLine(x + w, y, x + w - lineWidth, y, cropStrokePaint); + canvas.drawLine(x + w, y, x + w, y + lineWidth, cropStrokePaint); + canvas.drawLine(x + w, y + h, x + w - lineWidth, y + h, cropStrokePaint); + canvas.drawLine(x + w, y + h, x + w, y + h - lineWidth, cropStrokePaint); + } + + private boolean isShowImageRectLine = false; + private boolean canShowTouchLine = true; + private boolean isCircle = false; + + public void setCircle(boolean circle) { + isCircle = circle; + invalidate(); + } + + public void setShowImageRectLine(boolean showImageRectLine) { + isShowImageRectLine = showImageRectLine; + invalidate(); + } + + public void setCanShowTouchLine(boolean canShowTouchLine) { + this.canShowTouchLine = canShowTouchLine; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + try { + super.onDraw(canvas); + } catch (Exception ignored) { + loadMaxSize = (int) (loadMaxSize * 0.8); + setImageBitmap(originalBitmap); + return; + } + + if (isShowLine && canShowTouchLine && !isCircle) { + int left, top, right, bottom, w, h; + if (isShowImageRectLine) { + left = mImgRect.left > mCropRect.left ? (int) mImgRect.left : (int) mCropRect.left; + top = (int) mImgRect.top > mCropRect.top ? (int) mImgRect.top : (int) mCropRect.top; + right = mImgRect.right < mCropRect.right ? (int) mImgRect.right : (int) mCropRect.right; + bottom = mImgRect.bottom < mCropRect.bottom ? (int) mImgRect.bottom : (int) mCropRect.bottom; + w = right - left; + h = bottom - top; + } else { + w = (int) mCropRect.width(); + h = (int) mCropRect.height(); + left = (int) mCropRect.left; + top = (int) mCropRect.top; + } + canvas.drawLine(left + w / 3.0f, top, left + w / 3.0f, h + top, linePaint); + canvas.drawLine(left + w * 2 / 3.0f, top, left + w * 2 / 3.0f, h + top, linePaint); + canvas.drawLine(left, top + h / 3.0f, left + w, top + h / 3.0f, linePaint); + canvas.drawLine(left, top + h * 2 / 3.0f, left + w, top + h * 2 / 3.0f, linePaint); + } + + if (!isShowCropRect || aspectY <= 0 || aspectX <= 0) { + return; + } + + getDrawingRect(viewDrawingRect); + path.reset(); + if (isCircle) { + path.addCircle(mCropRect.left + mCropRect.width() / 2, mCropRect.top + mCropRect.height() / 2, mCropRect.width() / 2, Path.Direction.CW); + } else { + drawStrokeLine(canvas); + path.addRect(mCropRect.left, mCropRect.top, mCropRect.right, mCropRect.bottom, Path.Direction.CW); + } + canvas.clipPath(path, android.graphics.Region.Op.DIFFERENCE); + canvas.drawRect(viewDrawingRect, maskPaint); + canvas.drawPath(path, cropRectPaint); + } + + @Override + public void draw(Canvas canvas) { + if (mClip != null) { + canvas.clipRect(mClip); + mClip = null; + } + super.draw(canvas); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (isEnable) { + final int Action = event.getActionMasked(); + if (event.getPointerCount() >= 2) hasMultiTouch = true; + + mDetector.onTouchEvent(event); + if (isRotateEnable) { + mRotateDetector.onTouchEvent(event); + } + mScaleDetector.onTouchEvent(event); + if (Action == MotionEvent.ACTION_DOWN) { + isShowLine = true; + invalidate(); + } else if (Action == MotionEvent.ACTION_UP || Action == MotionEvent.ACTION_CANCEL) { + onUp(); + isShowLine = false; + invalidate(); + } + + return true; + } else { + return super.dispatchTouchEvent(event); + } + } + + private void onUp() { + if (mTranslate.isRunning) + return; + + if (canRotate || mDegrees % 90 != 0) { + float toDegrees = (int) (mDegrees / 90) * 90; + float remainder = mDegrees % 90; + + if (remainder > 45) + toDegrees += 90; + else if (remainder < -45) + toDegrees -= 90; + + mTranslate.withRotate((int) mDegrees, (int) toDegrees); + + mDegrees = toDegrees; + } + + if (!isBounceEnable) { + return; + } + + float cx = mImgRect.left * 1.00f + mImgRect.width() / 2; + float cy = mImgRect.top * 1.00f + mImgRect.height() / 2; + + mRotateCenter.set(cx, cy); + + if (mScale < 1) { + mTranslate.withScale(mScale, 1); + mScale = 1; + } else if (mScale > mMaxScale) { + mTranslate.withScale(mScale, mMaxScale); + mScale = mMaxScale; + } + + mScaleCenter.set(cx, cy); + mTranslateX = 0; + mTranslateY = 0; + + mTmpMatrix.reset(); + mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mTmpMatrix.postTranslate(cx - mBaseRect.width() / 2, cy - mBaseRect.height() / 2); + mTmpMatrix.postScale(mScale, mScale, mScaleCenter.x, mScaleCenter.y); + mTmpMatrix.postRotate(mDegrees, cx, cy); + mTmpMatrix.mapRect(mTmpRect, mBaseRect); + + doTranslateReset(mTmpRect); + mTranslate.start(); + } + + private boolean isBounceEnable = true; + + public void setBounceEnable(boolean isBounceEnable) { + this.isBounceEnable = isBounceEnable; + } + + private void doTranslateReset(RectF imgRect) { + int tx = 0; + int ty = 0; + + int width = (int) (mCropRect.width()); + int height = (int) (mCropRect.height()); + + if (imgRect.width() <= width) { + if (!isImageCenterWidth(imgRect)) { + if (aspectX > 0 && aspectY > 0) { + tx = (int) (imgRect.left - mCropRect.left); + } else { + tx = -(int) ((mCropRect.width() - imgRect.width()) / 2 - imgRect.left); + } + } + } else { + if (imgRect.left > mCropRect.left) { + tx = (int) (imgRect.left - mCropRect.left); + } else if (imgRect.right < mCropRect.right) { + tx = (int) (imgRect.right - mCropRect.right); + } + } + + if (imgRect.height() <= height) { + if (!isImageCenterHeight(imgRect)) + if (aspectX > 0 && aspectY > 0) { + ty = (int) (imgRect.top - mCropRect.top); + } else { + ty = -(int) ((mCropRect.height() - imgRect.height()) / 2 - imgRect.top); + } + } else { + if (imgRect.top > mCropRect.top) { + ty = (int) (imgRect.top - mCropRect.top); + } else if (imgRect.bottom < mCropRect.bottom) { + ty = (int) (imgRect.bottom - mCropRect.bottom); + } + } + + if (tx != 0 || ty != 0) { + if (!mTranslate.mFlingScroller.isFinished()) mTranslate.mFlingScroller.abortAnimation(); + mTranslate.withTranslate(mTranslateX, mTranslateY, -tx, -ty); + } + } + + private boolean isImageCenterHeight(RectF rect) { + return Math.abs(Math.round(rect.top) - (mCropRect.height() - rect.height()) / 2) < 1; + } + + private boolean isImageCenterWidth(RectF rect) { + return Math.abs(Math.round(rect.left) - (mCropRect.width() - rect.width()) / 2) < 1; + } + + private RotateGestureDetector.OnRotateListener mRotateListener = new RotateGestureDetector.OnRotateListener() { + @Override + public void onRotate(float degrees, float focusX, float focusY) { + mRotateFlag += degrees; + if (canRotate) { + mDegrees += degrees; + mAnimMatrix.postRotate(degrees, focusX, focusY); + } else { + if (Math.abs(mRotateFlag) >= mMinRotate) { + canRotate = true; + mRotateFlag = 0; + } + } + } + }; + + private ScaleGestureDetector.OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() { + @Override + public boolean onScale(ScaleGestureDetector detector) { + float scaleFactor = detector.getScaleFactor(); + + if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) + return false; + + if (mScale > mMaxScale) { + return true; + } + + mScale *= scaleFactor; + mScaleCenter.set(detector.getFocusX(), detector.getFocusY()); + mAnimMatrix.postScale(scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY()); + executeTranslate(); + return true; + } + + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + public void onScaleEnd(ScaleGestureDetector detector) { + } + }; + + private float resistanceScrollByX(float overScroll, float detalX) { + return detalX * (Math.abs(Math.abs(overScroll) - MAX_OVER_RESISTANCE) / (float) MAX_OVER_RESISTANCE); + } + + private float resistanceScrollByY(float overScroll, float detalY) { + return detalY * (Math.abs(Math.abs(overScroll) - MAX_OVER_RESISTANCE) / (float) MAX_OVER_RESISTANCE); + } + + /** + * 匹配两个Rect的共同部分输出到out,若无共同部分则输出0,0,0,0 + */ + private void mapRect(RectF r1, RectF r2, RectF out) { + float l, r, t, b; + + l = r1.left > r2.left ? r1.left : r2.left; + r = r1.right < r2.right ? r1.right : r2.right; + + if (l > r) { + out.set(0, 0, 0, 0); + return; + } + + t = r1.top > r2.top ? r1.top : r2.top; + b = r1.bottom < r2.bottom ? r1.bottom : r2.bottom; + + if (t > b) { + out.set(0, 0, 0, 0); + return; + } + + out.set(l, t, r, b); + } + + private void checkRect() { + if (!hasOverTranslate) { + mapRect(mCropRect, mImgRect, mCommonRect); + } + } + + private Runnable mClickRunnable = new Runnable() { + @Override + public void run() { + if (mClickListener != null) { + mClickListener.onClick(CropImageView.this); + } + } + }; + + private GestureDetector.OnGestureListener mGestureListener = new GestureDetector.SimpleOnGestureListener() { + @Override + public void onLongPress(MotionEvent e) { + if (mLongClick != null) { + mLongClick.onLongClick(CropImageView.this); + } + } + + @Override + public boolean onDown(MotionEvent e) { + hasOverTranslate = false; + hasMultiTouch = false; + canRotate = false; + removeCallbacks(mClickRunnable); + return false; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (hasMultiTouch) return false; + if (!imgLargeWidth && !imgLargeHeight) return false; + if (mTranslate.isRunning) return false; + + float vx = velocityX; + float vy = velocityY; + + if (Math.round(mImgRect.left) >= mCropRect.left || Math.round(mImgRect.right) <= mCropRect.right) { + vx = 0; + } + + if (Math.round(mImgRect.top) >= mCropRect.top || Math.round(mImgRect.bottom) <= mCropRect.bottom) { + vy = 0; + } + + if (canRotate || mDegrees % 90 != 0) { + float toDegrees = (int) (mDegrees / 90) * 90; + float remainder = mDegrees % 90; + + if (remainder > 45) + toDegrees += 90; + else if (remainder < -45) + toDegrees -= 90; + + mTranslate.withRotate((int) mDegrees, (int) toDegrees); + mDegrees = toDegrees; + } + mTranslate.withFling(vx, vy); + return super.onFling(e1, e2, velocityX, velocityY); + } + + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + if (mTranslate.isRunning) { + mTranslate.stop(); + } + if (canScrollHorizontallySelf(distanceX)) { + if (distanceX < 0 && mImgRect.left - distanceX > mCropRect.left) + distanceX = mImgRect.left; + if (distanceX > 0 && mImgRect.right - distanceX < mCropRect.right) + distanceX = mImgRect.right - mCropRect.right; + + mAnimMatrix.postTranslate(-distanceX, 0); + mTranslateX -= distanceX; + } else if (imgLargeWidth || hasMultiTouch || hasOverTranslate || !isBounceEnable) { + checkRect(); + if (!hasMultiTouch || !isBounceEnable) { + if (distanceX < 0 && mImgRect.left - distanceX > mCommonRect.left) + distanceX = resistanceScrollByX(mImgRect.left - mCommonRect.left, distanceX); + if (distanceX > 0 && mImgRect.right - distanceX < mCommonRect.right) + distanceX = resistanceScrollByX(mImgRect.right - mCommonRect.right, distanceX); + } + + mTranslateX -= distanceX; + mAnimMatrix.postTranslate(-distanceX, 0); + hasOverTranslate = true; + } + + if (canScrollVerticallySelf(distanceY)) { + if (distanceY < 0 && mImgRect.top - distanceY > mCropRect.top) + distanceY = mImgRect.top; + if (distanceY > 0 && mImgRect.bottom - distanceY < mCropRect.bottom) + distanceY = mImgRect.bottom - mCropRect.bottom; + + mAnimMatrix.postTranslate(0, -distanceY); + mTranslateY -= distanceY; + } else if (imgLargeHeight || hasOverTranslate || hasMultiTouch || !isBounceEnable) { + checkRect(); + if (!hasMultiTouch || !isBounceEnable) { + if (distanceY < 0 && mImgRect.top - distanceY > mCommonRect.top) + distanceY = resistanceScrollByY(mImgRect.top - mCommonRect.top, distanceY); + if (distanceY > 0 && mImgRect.bottom - distanceY < mCommonRect.bottom) + distanceY = resistanceScrollByY(mImgRect.bottom - mCommonRect.bottom, distanceY); + } + + mAnimMatrix.postTranslate(0, -distanceY); + mTranslateY -= distanceY; + hasOverTranslate = true; + } + + executeTranslate(); + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + postDelayed(mClickRunnable, 250); + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + mTranslate.stop(); + + float from; + float to; + + float imageCenterX = mImgRect.left + mImgRect.width() / 2; + float imageCenterY = mImgRect.top + mImgRect.height() / 2; + + mScaleCenter.set(imageCenterX, imageCenterY); + mRotateCenter.set(imageCenterX, imageCenterY); + mTranslateX = 0; + mTranslateY = 0; + + if (mScale > 1) { + from = mScale; + to = 1; + } else { + from = mScale; + to = mMaxScale; + mScaleCenter.set(e.getX(), e.getY()); + } + + mTmpMatrix.reset(); + mTmpMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mTmpMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y); + mTmpMatrix.postTranslate(-mBaseRect.width() / 2, -mBaseRect.height() / 2); + mTmpMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y); + mTmpMatrix.postScale(to, to, mScaleCenter.x, mScaleCenter.y); + mTmpMatrix.postTranslate(mTranslateX, mTranslateY); + mTmpMatrix.mapRect(mTmpRect, mBaseRect); + doTranslateReset(mTmpRect); + + isZoomUp = !isZoomUp; + mTranslate.withScale(from, to); + mTranslate.start(); + + return false; + } + }; + + public boolean canScrollHorizontallySelf(float direction) { + if (mImgRect.width() <= mCropRect.width()) return false; + if (direction < 0 && Math.round(mImgRect.left) - direction >= mCropRect.left) + return false; + return !(direction > 0) || !(Math.round(mImgRect.right) - direction <= mCropRect.right); + } + + public boolean canScrollVerticallySelf(float direction) { + if (mImgRect.height() <= mCropRect.height()) return false; + if (direction < 0 && Math.round(mImgRect.top) - direction >= mCropRect.top) + return false; + return !(direction > 0) || !(Math.round(mImgRect.bottom) - direction <= mCropRect.bottom); + } + + @Override + public boolean canScrollHorizontally(int direction) { + if (!isEnable) { + return super.canScrollHorizontally(direction); + } + if (hasMultiTouch) return true; + return canScrollHorizontallySelf(direction); + } + + @Override + public boolean canScrollVertically(int direction) { + if (!isEnable) { + return super.canScrollVertically(direction); + } + if (hasMultiTouch) return true; + return canScrollVerticallySelf(direction); + } + + private class InterpolatorProxy implements Interpolator { + + private Interpolator mTarget; + + private InterpolatorProxy() { + mTarget = new DecelerateInterpolator(); + } + + void setTargetInterpolator(Interpolator interpolator) { + mTarget = interpolator; + } + + @Override + public float getInterpolation(float input) { + if (mTarget != null) { + return mTarget.getInterpolation(input); + } + return input; + } + } + + private class Transform implements Runnable { + + boolean isRunning; + + OverScroller mTranslateScroller; + OverScroller mFlingScroller; + Scroller mScaleScroller; + Scroller mClipScroller; + Scroller mRotateScroller; + + ClipCalculate C; + + int mLastFlingX; + int mLastFlingY; + + int mLastTranslateX; + int mLastTranslateY; + + RectF mClipRect = new RectF(); + + InterpolatorProxy mInterpolatorProxy = new InterpolatorProxy(); + + Transform() { + Context ctx = getContext(); + mTranslateScroller = new OverScroller(ctx, mInterpolatorProxy); + mScaleScroller = new Scroller(ctx, mInterpolatorProxy); + mFlingScroller = new OverScroller(ctx, mInterpolatorProxy); + mClipScroller = new Scroller(ctx, mInterpolatorProxy); + mRotateScroller = new Scroller(ctx, mInterpolatorProxy); + } + + public void setInterpolator(Interpolator interpolator) { + mInterpolatorProxy.setTargetInterpolator(interpolator); + } + + void withTranslate(int startX, int startY, int deltaX, int deltaY) { + mLastTranslateX = 0; + mLastTranslateY = 0; + mTranslateScroller.startScroll(startX, startY, deltaX, deltaY, mAnimDuring); + } + + void withScale(float form, float to) { + mScaleScroller.startScroll((int) (form * 10000), 0, (int) ((to - form) * 10000), 0, mAnimDuring); + } + + void withRotate(int fromDegrees, int toDegrees) { + mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, mAnimDuring); + } + + void withRotate(int fromDegrees, int toDegrees, int during) { + mRotateScroller.startScroll(fromDegrees, 0, toDegrees - fromDegrees, 0, during); + } + + void withFling(float velocityX, float velocityY) { + mLastFlingX = velocityX < 0 ? Integer.MAX_VALUE : 0; + int distanceX = (int) (velocityX > 0 ? Math.abs(mImgRect.left) : mImgRect.right - mCropRect.right); + distanceX = velocityX < 0 ? Integer.MAX_VALUE - distanceX : distanceX; + int minX = velocityX < 0 ? distanceX : 0; + int maxX = velocityX < 0 ? Integer.MAX_VALUE : distanceX; + int overX = velocityX < 0 ? Integer.MAX_VALUE - minX : distanceX; + + mLastFlingY = velocityY < 0 ? Integer.MAX_VALUE : 0; + int distanceY = (int) (velocityY > 0 ? Math.abs(mImgRect.top - mCropRect.top) : mImgRect.bottom - mCropRect.bottom); + distanceY = velocityY < 0 ? Integer.MAX_VALUE - distanceY : distanceY; + int minY = velocityY < 0 ? distanceY : 0; + int maxY = velocityY < 0 ? Integer.MAX_VALUE : distanceY; + int overY = velocityY < 0 ? Integer.MAX_VALUE - minY : distanceY; + + if (velocityX == 0) { + maxX = 0; + minX = 0; + } + + if (velocityY == 0) { + maxY = 0; + minY = 0; + } + + mFlingScroller.fling(mLastFlingX, mLastFlingY, (int) velocityX, (int) velocityY, minX, maxX, minY, maxY, + Math.abs(overX) < MAX_FLING_OVER_SCROLL * 2 ? 0 : MAX_FLING_OVER_SCROLL, + Math.abs(overY) < MAX_FLING_OVER_SCROLL * 2 ? 0 : MAX_FLING_OVER_SCROLL); + } + + void start() { + isRunning = true; + postExecute(); + } + + void stop() { + removeCallbacks(this); + mTranslateScroller.abortAnimation(); + mScaleScroller.abortAnimation(); + mFlingScroller.abortAnimation(); + mRotateScroller.abortAnimation(); + isRunning = false; + } + + @Override + public void run() { + if (!isRunning) return; + + boolean endAnim = true; + + if (mScaleScroller.computeScrollOffset()) { + mScale = mScaleScroller.getCurrX() / 10000f; + endAnim = false; + } + + if (mTranslateScroller.computeScrollOffset()) { + int tx = mTranslateScroller.getCurrX() - mLastTranslateX; + int ty = mTranslateScroller.getCurrY() - mLastTranslateY; + mTranslateX += tx; + mTranslateY += ty; + mLastTranslateX = mTranslateScroller.getCurrX(); + mLastTranslateY = mTranslateScroller.getCurrY(); + endAnim = false; + } + + if (mFlingScroller.computeScrollOffset()) { + int x = mFlingScroller.getCurrX() - mLastFlingX; + int y = mFlingScroller.getCurrY() - mLastFlingY; + + mLastFlingX = mFlingScroller.getCurrX(); + mLastFlingY = mFlingScroller.getCurrY(); + + mTranslateX += x; + mTranslateY += y; + endAnim = false; + } + + if (mRotateScroller.computeScrollOffset()) { + mDegrees = mRotateScroller.getCurrX(); + endAnim = false; + } + + if (mClipScroller.computeScrollOffset() || mClip != null) { + float sx = mClipScroller.getCurrX() / 10000f; + float sy = mClipScroller.getCurrY() / 10000f; + mTmpMatrix.setScale(sx, sy, (mImgRect.left + mImgRect.right) / 2, C.calculateTop()); + mTmpMatrix.mapRect(mClipRect, mImgRect); + + if (sx == 1) { + mClipRect.left = mCropRect.left; + mClipRect.right = mCropRect.right; + } + + if (sy == 1) { + mClipRect.top = mCropRect.top; + mClipRect.bottom = mCropRect.bottom; + } + + mClip = mClipRect; + } + if (!endAnim) { + applyAnim(); + postExecute(); + } else { + isRunning = false; + if (aspectX > 0 && aspectY > 0) { + return; + } + // 修复动画结束后边距有些空隙, + boolean needFix = false; + if (imgLargeWidth) { + if (mImgRect.left > 0) { + mTranslateX -= mCropRect.left; + } else if (mImgRect.right < mCropRect.width()) { + mTranslateX -= (int) (mCropRect.width() - mImgRect.right); + } + needFix = true; + } + + if (imgLargeHeight) { + if (mImgRect.top > 0) { + mTranslateY -= mCropRect.top; + } else if (mImgRect.bottom < mCropRect.height()) { + mTranslateY -= (int) (mCropRect.height() - mImgRect.bottom); + } + needFix = true; + } + + if (needFix) { + applyAnim(); + } + + invalidate(); + } + if (mCompleteCallBack != null) { + mCompleteCallBack.run(); + mCompleteCallBack = null; + } + } + + private void applyAnim() { + mAnimMatrix.reset(); + mAnimMatrix.postTranslate(-mBaseRect.left, -mBaseRect.top); + mAnimMatrix.postTranslate(mRotateCenter.x, mRotateCenter.y); + mAnimMatrix.postTranslate(-mBaseRect.width() / 2, -mBaseRect.height() / 2); + mAnimMatrix.postRotate(mDegrees, mRotateCenter.x, mRotateCenter.y); + mAnimMatrix.postScale(mScale, mScale, mScaleCenter.x, mScaleCenter.y); + mAnimMatrix.postTranslate(mTranslateX, mTranslateY); + executeTranslate(); + } + + private void postExecute() { + if (isRunning) post(this); + } + } + + public Info getInfo() { + return new Info(mImgRect, mCropRect, mDegrees, mScaleType.name(), aspectX, aspectY, getTranslateX(), getTranslateY(), getScale()); + } + + public interface ClipCalculate { + float calculateTop(); + } + + public void rotate(float degrees) { + mDegrees += degrees; + int centerX = (int) (mCropRect.left + mCropRect.width() / 2); + int centerY = (int) (mCropRect.top + mCropRect.height() / 2); + + mAnimMatrix.postRotate(degrees, centerX, centerY); + executeTranslate(); + } + + public Bitmap generateCropBitmapFromView(final int backgroundColor) { + ((Activity) getContext()).runOnUiThread(new Runnable() { + @Override + public void run() { + setShowImageRectLine(false); + isShowCropRect = false; + invalidate(); + } + }); + + Bitmap bitmap = PBitmapUtils.getViewBitmap(CropImageView.this); + try { + bitmap = Bitmap.createBitmap(bitmap, (int) mCropRect.left, (int) mCropRect.top, + (int) mCropRect.width(), (int) mCropRect.height()); + if (isCircle) { + bitmap = createCircleBitmap(bitmap, backgroundColor); + } + } catch (Exception ignored) { + } + return bitmap; + } + + /** + * 生成剪裁图片 + * + * @return bitmap + */ + public Bitmap generateCropBitmap() { + if (originalBitmap == null) { + return null; + } + //水平平移像素点 + float x = Math.abs(getTranslateX()); + //垂直平移像素点 + float y = Math.abs(getTranslateY()); + //缩放比例 + float scale = mScale; + //原图宽度(Glide压缩过的,Glide默认加载会减小大图的宽高) + int bw = originalBitmap.getWidth(); + //原图高度(Glide压缩过的) + int bh = originalBitmap.getHeight(); + //图片宽高比 + float bRatio = bw * 1.00f / (bh * 1.00f); + + float endW; + float endH; + float endX; + float endY; + + float cropWidth = mCropRect.width(); + float cropHeight = mCropRect.height(); + float cropRatio = (cropWidth * 1.00f / (cropHeight * 1.00f)); + + //图片比例小于剪裁比例,以宽填满,高自适应,计算高 + if (bRatio < cropRatio) { + endW = bw / scale; + endH = endW / cropRatio; + endX = bw * x / (cropWidth * scale * 1.00f); + endY = bw * y / (cropWidth * scale * 1.00f); + } else { + endH = bh / scale; + endW = cropRatio * endH; + endX = bh * x / (cropHeight * scale * 1.00f); + endY = bh * y / (cropHeight * scale * 1.00f); + } + + if (endX + endW > bw) { + endX = bw - endW; + if (endX < 0) { + endX = 0; + } + } + + if (endY + endH > bh) { + endY = bh - endH; + if (endY < 0) { + endY = 0; + } + } + + Bitmap bitmap1; + try { + bitmap1 = Bitmap.createBitmap(originalBitmap, (int) endX, (int) endY, (int) endW, (int) endH); +// if (isCircle) { +// bitmap1 = createCircleBitmap(bitmap1, Color.TRANSPARENT); +// } + } catch (Exception ignored) { + bitmap1 = generateCropBitmapFromView(Color.BLACK); + } + return bitmap1; + } + + public void setShowCropRect(boolean showCropRect) { + isShowCropRect = showCropRect; + invalidate(); + } + + private Bitmap createCircleBitmap(Bitmap resource, int backgroundColor) { + int width = resource.getWidth(); + Paint paint = new Paint(); + paint.setAntiAlias(true); + Bitmap circleBitmap = Bitmap.createBitmap(resource.getWidth(), resource.getHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(circleBitmap); + if (backgroundColor != Color.TRANSPARENT) { + paint.setColor(backgroundColor); + } + canvas.drawCircle(width / 2, width / 2, width / 2, paint); + //设置画笔为取交集模式 + if (backgroundColor == Color.TRANSPARENT) { + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + } else { + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)); + } + + //裁剪图片 + canvas.drawBitmap(resource, 0, 0, paint); + return circleBitmap; + } + + + public float getTranslateX() { + return mImgRect.left - mCropRect.left; + } + + public float getTranslateY() { + return mImgRect.top - mCropRect.top; + } + + public float getScale() { + if (mScale <= 1) { + return 1; + } + return mScale; + } + + public int dp(float dp) { + float density = getContext().getResources().getDisplayMetrics().density; + return (int) (dp * density + 0.5); + } + + + private ValueAnimator cropAnim; + + private void anim(float left, float top, float right, float bottom) { + final float oldLeft = mCropRect.left; + final float oldTop = mCropRect.top; + final float oldRight = mCropRect.right; + final float oldBottom = mCropRect.bottom; + final float finalLeft = left; + final float finalTop = top; + final float finalRight = right; + final float finalBottom = bottom; + + if ((oldRight == 0 || oldBottom == 0) || (oldLeft == left && oldBottom == bottom + && oldRight == right && oldTop == top)) { + mCropRect.set(finalLeft, finalTop, finalRight, finalBottom); + initBase(); + invalidate(); + return; + } + + if (cropAnim == null) { + cropAnim = ObjectAnimator.ofFloat(0.0F, 1.0F).setDuration(400); + cropAnim.setInterpolator(new DecelerateInterpolator()); + } + cropAnim.removeAllUpdateListeners(); + cropAnim.removeAllListeners(); + cropAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float value = (float) animation.getAnimatedValue(); + mCropRect.left = (finalLeft - oldLeft) * value + oldLeft; + mCropRect.top = (finalTop - oldTop) * value + oldTop; + mCropRect.right = (finalRight - oldRight) * value + oldRight; + mCropRect.bottom = (finalBottom - oldBottom) * value + oldBottom; + isShowLine = value < 1.0f; + initBase(); + invalidate(); + } + }); + cropAnim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + initBase(); + invalidate(); + } + }); + cropAnim.start(); + } + + public void changeSize(boolean isAnim, final int endWidth, final int endHeight) { + if (isAnim) { + final int startWidth = getWidth(); + final int startHeight = getHeight(); + ValueAnimator anim = ValueAnimator.ofFloat(0.0f, 1.0f).setDuration(200); + anim.setInterpolator(new DecelerateInterpolator()); + anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float ratio = (Float) animation.getAnimatedValue(); + ViewGroup.LayoutParams params = getLayoutParams(); + params.width = (int) ((endWidth - startWidth) * ratio + startWidth); + params.height = (int) ((endHeight - startHeight) * ratio + startHeight); + setLayoutParams(params); + setImageDrawable(getDrawable()); + } + }); + anim.start(); + } else { + ViewGroup.LayoutParams params = getLayoutParams(); + params.width = endWidth; + params.height = endHeight; + setLayoutParams(params); + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java new file mode 100644 index 0000000..f4d3d44 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/Info.java @@ -0,0 +1,106 @@ +package com.remax.visualnovel.widget.imagepicker.widget.cropimage; + +import android.graphics.RectF; +import android.os.Parcel; +import android.os.Parcelable; +import android.widget.ImageView; + +import java.io.Serializable; + +/** + * Description: 图片基本信息 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class Info implements Parcelable, Serializable { + // 控件在窗口的位置 + public RectF mImgRect = new RectF(); + public RectF mWidgetRect = new RectF(); + + public float mDegrees; + public float mCropX; + public float mCropY; + public String mScaleType; + + public float transitX; + public float transitY; + public float mScale; + + public ImageView.ScaleType getScaleType() { + return ImageView.ScaleType.valueOf(mScaleType); + } + + + public Info(RectF img, RectF widget, float degrees, String scaleType, float mCropX, + float mCropY, float transitX, float transitY, float mScale) { + mImgRect.set(img); + mWidgetRect.set(widget); + mScaleType = scaleType; + mDegrees = degrees; + this.mCropX = mCropX; + this.mCropY = mCropY; + this.transitX = transitX; + this.transitY = transitY; + this.mScale = mScale; + } + + protected Info(Parcel in) { + mImgRect = in.readParcelable(RectF.class.getClassLoader()); + mWidgetRect = in.readParcelable(RectF.class.getClassLoader()); + mScaleType = in.readString(); + mDegrees = in.readFloat(); + mCropX = in.readFloat(); + mCropY = in.readFloat(); + this.transitX = in.readFloat();; + this.transitY = in.readFloat();; + this.mScale = in.readFloat();; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Info createFromParcel(Parcel in) { + return new Info(in); + } + + @Override + public Info[] newArray(int size) { + return new Info[size]; + } + }; + + /** + * Describe the kinds of special objects contained in this Parcelable + * instance's marshaled representation. For example, if the object will + * include a file descriptor in the output of {@link #writeToParcel(Parcel, int)}, + * the return value of this method must include the + * {@link #CONTENTS_FILE_DESCRIPTOR} bit. + * + * @return a bitmask indicating the set of special object types marshaled + * by this Parcelable object instance. + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Flatten this object in to a Parcel. + * + * @param dest The Parcel in which the object should be written. + * @param flags Additional flags about how the object should be written. + * May be 0 or {@link #PARCELABLE_WRITE_RETURN_VALUE}. + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(mImgRect, flags); + dest.writeParcelable(mWidgetRect, flags); + dest.writeString(mScaleType); + dest.writeFloat(mDegrees); + dest.writeFloat(mCropX); + dest.writeFloat(mCropY); + dest.writeFloat(transitX); + dest.writeFloat(transitY); + dest.writeFloat(mScale); + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java new file mode 100644 index 0000000..09b30ae --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imagepicker/widget/cropimage/RotateGestureDetector.java @@ -0,0 +1,70 @@ +package com.remax.visualnovel.widget.imagepicker.widget.cropimage; + +import android.view.MotionEvent; + +/** + * Description: 旋转手势 + *

+ * Author: peixing.yang + * Date: 2019/2/21 + */ +public class RotateGestureDetector { + + private static final int MAX_DEGREES_STEP = 120; + + private OnRotateListener mListener; + + private float mPrevSlope; + private float mCurrSlope; + + private float x1; + private float y1; + private float x2; + private float y2; + + public RotateGestureDetector(OnRotateListener l) { + mListener = l; + } + + public void onTouchEvent(MotionEvent event) { + + final int Action = event.getActionMasked(); + + switch (Action) { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_POINTER_UP: + if (event.getPointerCount() == 2) mPrevSlope = caculateSlope(event); + break; + case MotionEvent.ACTION_MOVE: + if (event.getPointerCount() > 1) { + mCurrSlope = caculateSlope(event); + + double currDegrees = Math.toDegrees(Math.atan(mCurrSlope)); + double prevDegrees = Math.toDegrees(Math.atan(mPrevSlope)); + + double deltaSlope = currDegrees - prevDegrees; + + if (Math.abs(deltaSlope) <= MAX_DEGREES_STEP) { + mListener.onRotate((float) deltaSlope, (x2 + x1) / 2, (y2 + y1) / 2); + } + mPrevSlope = mCurrSlope; + } + break; + default: + break; + } + } + + private float caculateSlope(MotionEvent event) { + x1 = event.getX(0); + y1 = event.getY(0); + x2 = event.getX(1); + y2 = event.getY(1); + return (y2 - y1) / (x2 - x1); + } + + public interface OnRotateListener { + void onRotate(float degrees, float focusX, float focusY); + } +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt new file mode 100644 index 0000000..01b801d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/BaseDialogFragment.kt @@ -0,0 +1,90 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.app.Dialog +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import com.remax.visualnovel.R + +open class BaseDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return Dialog(requireActivity(), R.style.FullScreenDialog).apply { + setCanceledOnTouchOutside(true) + window?.let(::setWindow) + } + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.isFocusableInTouchMode = true + view.setOnKeyListener { _, keyCode, event -> + val backPressed = event.action == MotionEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK + if (backPressed) onBackPressed() + backPressed + } + } + + override fun onResume() { + super.onResume() + view?.requestFocus() + } + + override fun onDestroyView() { + super.onDestroyView() + view?.setOnKeyListener(null) + } + + open fun setWindow(win: Window) { + win.setWindowAnimations(R.style.Animation_Keep) +// win.decorView.setPadding(0, 0, 0, 0) +// val lp = win.attributes +// lp.width = WindowManager.LayoutParams.MATCH_PARENT +// lp.height = WindowManager.LayoutParams.MATCH_PARENT +// win.attributes = lp +// win.setGravity(Gravity.CENTER) + val layoutParams = WindowManager.LayoutParams() + layoutParams.copyFrom(win.attributes) + layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT + layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT + + // 设置系统UI的可见性,让内容延伸到状态栏和导航栏后面 + win.attributes = layoutParams + + // 设置系统UI的Flag,使内容可以延伸到状态栏和导航栏区域 + win.decorView.setSystemUiVisibility( + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + ) + + // 设置状态栏和导航栏透明 + win.statusBarColor = android.graphics.Color.TRANSPARENT + win.navigationBarColor = android.graphics.Color.TRANSPARENT + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + win.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + } + + fun show(fragmentManager: FragmentManager?) { + when { + fragmentManager == null -> showFailure()//"fragmentManager is detach after parent destroy" + fragmentManager.isStateSaved -> showFailure()//"dialog fragment show when fragmentManager isStateSaved" + else -> show(fragmentManager, javaClass.simpleName) + } + } + + open fun showFailure(message: String? = null) { + } + + open fun onBackPressed() { + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt new file mode 100644 index 0000000..00174d7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerActionViewModel.kt @@ -0,0 +1,21 @@ +package com.remax.visualnovel.widget.imageviewer + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.remax.visualnovel.widget.imageviewer.ViewerActions +import com.remax.visualnovel.widget.imageviewer.core.Photo + +class ImageViewerActionViewModel : ViewModel() { + private val _actionEvent = MutableLiveData?>() + val actionEvent: LiveData?> = _actionEvent + + fun setCurrentItem(pos: Int) = internalHandle(ViewerActions.SET_CURRENT_ITEM, pos) + fun dismiss() = internalHandle(ViewerActions.DISMISS, null) + fun remove(item: List) = internalHandle(ViewerActions.REMOVE_ITEMS, item) + + private fun internalHandle(action: String, extra: Any?) { + _actionEvent.value = Pair(action, extra) + _actionEvent.value = null + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt new file mode 100644 index 0000000..11c09e0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerAdapterListener.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +interface ImageViewerAdapterListener { + fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) + fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) + fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) + fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt new file mode 100644 index 0000000..4b3924a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerBuilder.kt @@ -0,0 +1,61 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.remax.visualnovel.widget.imageviewer.ImageViewerDialogFragment +import com.remax.visualnovel.widget.imageviewer.core.Components +import com.remax.visualnovel.widget.imageviewer.core.DataProvider +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.OverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Transformer +import com.remax.visualnovel.widget.imageviewer.core.VHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.ViewerCallback + +class ImageViewerBuilder( + private val context: Context?, + val imageLoader: ImageLoader, + private val dataProvider: DataProvider, + private val transformer: Transformer, +) { + private var vhCustomizer: VHCustomizer? = null + private var viewerCallback: ViewerCallback? = null + private var overlayCustomizer: OverlayCustomizer? = null + private var factory: ImageViewerDialogFragment.Factory? = null + + fun setVHCustomizer(vhCustomizer: VHCustomizer): ImageViewerBuilder { + this.vhCustomizer = vhCustomizer + return this + } + + fun setViewerCallback(viewerCallback: ViewerCallback): ImageViewerBuilder { + this.viewerCallback = viewerCallback + return this + } + + fun setOverlayCustomizer(overlayCustomizer: OverlayCustomizer?): ImageViewerBuilder { + this.overlayCustomizer = overlayCustomizer + return this + } + + fun setViewerFactory(factory: ImageViewerDialogFragment.Factory?): ImageViewerBuilder { + this.factory = factory + return this + } + + private fun create(): ImageViewerDialogFragment { + return (factory ?: ImageViewerDialogFragment.Factory()).build() + } + + fun show() { + if (Components.working) return + (context as? FragmentActivity?)?.let { + Components.initialize(imageLoader, dataProvider, transformer) + Components.setVHCustomizer(vhCustomizer) + Components.setViewerCallback(viewerCallback) + Components.setOverlayCustomizer(overlayCustomizer) + val viewer = create() + viewer.show(it.supportFragmentManager) + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt new file mode 100644 index 0000000..c6dc253 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerDialogFragment.kt @@ -0,0 +1,190 @@ +package com.remax.visualnovel.widget.imageviewer + +import android.graphics.Color +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.Window +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.FragmentImageViewerDialogBinding +import com.remax.visualnovel.widget.imageviewer.adapter.ImageViewerAdapter +import com.remax.visualnovel.widget.imageviewer.core.Components +import com.remax.visualnovel.widget.imageviewer.core.Components.requireDataProvider +import com.remax.visualnovel.widget.imageviewer.core.Components.requireOverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Components.requireTransformer +import com.remax.visualnovel.widget.imageviewer.core.Components.requireViewerCallback +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.utils.TransitionEndHelper +import com.remax.visualnovel.widget.imageviewer.utils.TransitionStartHelper +import com.remax.visualnovel.widget.imageviewer.utils.findViewWithKeyTag +import kotlin.math.max + +open class ImageViewerDialogFragment : BaseDialogFragment() { + private var innerBinding: FragmentImageViewerDialogBinding? = null + private val binding get() = innerBinding!! + private val viewModel by lazy { ViewModelProvider(this)[ImageViewerViewModel::class.java] } + private val actions by lazy { ViewModelProvider(requireActivity())[ImageViewerActionViewModel::class.java] } + private val userCallback by lazy { requireViewerCallback() } + private val initKey by lazy { requireDataProvider().loadInitial().first().id() } + private val transformer by lazy { requireTransformer() } + private val adapter by lazy { ImageViewerAdapter(initKey) } + private val taskId = 110 + private var submitPagingData = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!Components.working) dismissAllowingStateLoss() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + innerBinding = + innerBinding ?: FragmentImageViewerDialogBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + adapter.setListener(adapterListener) + (binding.viewer.getChildAt(0) as? RecyclerView?)?.let { + it.clipChildren = false + it.itemAnimator = null + } + binding.viewer.registerOnPageChangeCallback(pagerCallback) + binding.viewer.orientation = Config.VIEWER_ORIENTATION + binding.viewer.adapter = adapter + + requireOverlayCustomizer().provideView(binding.overlayView)?.let(binding.overlayView::addView) + + viewModel.pagingData.observe(viewLifecycleOwner) { + submitPagingData = true + adapter.submitData(viewLifecycleOwner.lifecycle, it) + } + viewModel.viewerUserInputEnabled.observe(viewLifecycleOwner) { + binding.viewer.isUserInputEnabled = it ?: true + } + actions.actionEvent.observe(viewLifecycleOwner, Observer(::handle)) + } + + private fun handle(action: Pair?) { + when (action?.first) { + ViewerActions.SET_CURRENT_ITEM -> binding.viewer.currentItem = max(action.second as Int, 0) + ViewerActions.DISMISS -> onBackPressed() + ViewerActions.REMOVE_ITEMS -> viewModel.remove(adapter, action.second) { onBackPressed() } + } + } + + private val adapterListener by lazy { + object : ImageViewerAdapterListener { + override fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) { + TransitionStartHelper.start(this@ImageViewerDialogFragment, transformer.getView(initKey), viewHolder) + binding.background.changeToBackgroundColor(Config.VIEWER_BACKGROUND_COLOR) + userCallback.onInit(viewHolder, position) + } + + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + binding.background.updateBackgroundColor(fraction, Config.VIEWER_BACKGROUND_COLOR, Color.TRANSPARENT) + userCallback.onDrag(viewHolder, view, fraction) + } + + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + binding.background.changeToBackgroundColor(Config.VIEWER_BACKGROUND_COLOR) + userCallback.onRestore(viewHolder, view, fraction) + } + + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { + val startView = (view.getTag(R.id.viewer_adapter_item_key) as? Long?)?.let { transformer.getView(it) } + TransitionEndHelper.end(this@ImageViewerDialogFragment, startView, viewHolder) + binding.background.changeToBackgroundColor(Color.TRANSPARENT) + userCallback.onRelease(viewHolder, view) + } + } + } + + private val pagerCallback by lazy { + object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + userCallback.onPageScrollStateChanged(state) + } + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + userCallback.onPageScrolled(position, positionOffset, positionOffsetPixels) + } + + override fun onPageSelected(position: Int) { + val currentKey = viewModel.snapshot[position].id() + val holder = binding.viewer.findViewWithKeyTag(R.id.viewer_adapter_item_key, currentKey) + ?.getTag(R.id.viewer_adapter_item_holder) as? RecyclerView.ViewHolder? + ?: return + + if (submitPagingData) { + submitPagingData = false + viewerHandler.removeMessages(taskId) + viewerHandler.sendMessageDelayed( + Message.obtain(viewerHandler, taskId, position, 0, holder), + Config.VIEWER_FIRST_PAGE_SELECTED_DELAY + ) + return + } + viewerHandler.removeMessages(taskId) + Handler(Looper.getMainLooper()).post { + userCallback.onPageSelected(position, adapter.getPositionData(position), adapter.itemCount, holder) + } + } + } + } + + override fun showFailure(message: String?) { + super.showFailure(message) + Components.release() + } + + override fun onDestroyView() { + super.onDestroyView() + viewerHandler.removeMessages(taskId) + adapter.setListener(null) + binding.viewer.unregisterOnPageChangeCallback(pagerCallback) + binding.viewer.adapter = null + innerBinding = null + Components.release() + } + + override fun onBackPressed() { + if (TransitionStartHelper.transitionAnimating || TransitionEndHelper.transitionAnimating) return + + val currentKey = viewModel.snapshot[binding.viewer.currentItem].id() + binding.viewer.findViewWithKeyTag(R.id.viewer_adapter_item_key, currentKey)?.let { endView -> + val startView = transformer.getView(endView.getTag(R.id.viewer_adapter_item_key) as Long) + binding.background.changeToBackgroundColor(Color.TRANSPARENT) + + (endView.getTag(R.id.viewer_adapter_item_holder) as? RecyclerView.ViewHolder?)?.let { + TransitionEndHelper.end(this, startView, it) + userCallback.onRelease(it, endView) + } + } + } + + private val viewerHandler by lazy { + Handler(Looper.getMainLooper()) { + it.target.removeMessages(it.what) + userCallback.onPageSelected( + it.arg1, + adapter.getPositionData(it.arg1), + adapter.itemCount, + it.obj as RecyclerView.ViewHolder + ) + true + } + } + + open class Factory { + open fun build(): ImageViewerDialogFragment = ImageViewerDialogFragment() + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt new file mode 100644 index 0000000..c59ec3d --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ImageViewerViewModel.kt @@ -0,0 +1,26 @@ +package com.remax.visualnovel.widget.imageviewer + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.paging.PagingData +import com.remax.visualnovel.widget.imageviewer.adapter.ImageViewerAdapter +import com.remax.visualnovel.widget.imageviewer.adapter.Repository +import com.remax.visualnovel.widget.imageviewer.core.Photo + +@Suppress("UNCHECKED_CAST") +class ImageViewerViewModel : ViewModel() { + private val repository = Repository() + val snapshot: List get() = repository.snapshot + val pagingData: LiveData> = repository.pagingData + val viewerUserInputEnabled = MutableLiveData() + + fun setViewerUserInputEnabled(enable: Boolean) { + if (viewerUserInputEnabled.value != enable) viewerUserInputEnabled.value = enable + } + + fun remove(adapter: ImageViewerAdapter, item: Any?, emptyCallback: () -> Unit) { + val removed = (item as? List?) ?: return + repository.redirect(adapter, removed, emptyCallback) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt new file mode 100644 index 0000000..f7fc11e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/ViewerActions.kt @@ -0,0 +1,7 @@ +package com.remax.visualnovel.widget.imageviewer + +object ViewerActions { + const val SET_CURRENT_ITEM = "setCurrentItem" + const val DISMISS = "dismiss" + const val REMOVE_ITEMS = "removeItems" +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt new file mode 100644 index 0000000..fe997f0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ImageViewerAdapter.kt @@ -0,0 +1,93 @@ +package com.remax.visualnovel.widget.imageviewer.adapter + +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.viewholders.PhotoViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.SubsamplingViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.UnknownViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.VideoViewHolder +import java.util.Objects + +class ImageViewerAdapter(initKey: Long) : PagingDataAdapter(diff) { + private var listener: ImageViewerAdapterListener? = null + private var key = initKey + + fun setListener(callback: ImageViewerAdapterListener?) { + listener = callback + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ItemType.PHOTO -> PhotoViewHolder(parent, callback) + ItemType.SUBSAMPLING -> SubsamplingViewHolder(parent, callback) + ItemType.VIDEO -> VideoViewHolder(parent, callback) + else -> UnknownViewHolder(View(parent.context)) + } + } + + fun getPositionData(position: Int) = provideItem(position) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = provideItem(position) + when (holder) { + is PhotoViewHolder -> item?.let { holder.bind(it, position) } + is SubsamplingViewHolder -> item?.let { holder.bind(it, position) } + is VideoViewHolder -> item?.let { holder.bind(it, position) } + } + if (item?.id() == key) { + listener?.onInit(holder, position) + key = NO_ID + } + } + + override fun getItemViewType(position: Int) = provideItem(position)?.itemType() ?: ItemType.UNKNOWN + private val callback: ImageViewerAdapterListener = object : ImageViewerAdapterListener { + override fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) { + listener?.onInit(viewHolder, position) + } + + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + listener?.onDrag(viewHolder, view, fraction) + } + + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { + listener?.onRelease(viewHolder, view) + } + + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { + listener?.onRestore(viewHolder, view, fraction) + } + } + + private fun provideItem(position: Int) = try { + // Fatal Exception: java.util.ConcurrentModificationException + // IndexOutOfBoundsException Item count is zero, getItem() call is invalid + getItem(position) + } catch (e: Throwable) { + null + } +} + +private val diff + get() = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Photo, + newItem: Photo + ): Boolean { + return newItem.itemType() == oldItem.itemType() && newItem.id() == oldItem.id() + } + + override fun areContentsTheSame( + oldItem: Photo, + newItem: Photo + ): Boolean { + return newItem.itemType() == oldItem.itemType() && newItem.id() == oldItem.id() + && Objects.equals(newItem.extra(), oldItem.extra()) + } + } diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt new file mode 100644 index 0000000..90d4e21 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/ItemType.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.widget.imageviewer.adapter + +import androidx.annotation.IntDef + +object ItemType { + const val UNKNOWN = -1 + const val PHOTO = 1 + const val SUBSAMPLING = 2 + const val VIDEO = 3 + + @Target(AnnotationTarget.TYPE) + @IntDef(PHOTO, SUBSAMPLING, VIDEO) + @Retention(AnnotationRetention.SOURCE) + annotation class Type +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt new file mode 100644 index 0000000..a2cb337 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/adapter/Repository.kt @@ -0,0 +1,83 @@ +package com.remax.visualnovel.widget.imageviewer.adapter + +import androidx.lifecycle.MutableLiveData +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.liveData +import com.remax.visualnovel.extension.resumeWithActive +import com.remax.visualnovel.widget.imageviewer.core.Components +import com.remax.visualnovel.widget.imageviewer.core.Photo +import kotlinx.coroutines.suspendCancellableCoroutine + +class Repository { + private val dataProvider by lazy { Components.requireDataProvider() } + private val dataList = MutableLiveData>() + internal val snapshot: List get() = dataList.value ?: listOf() + internal val pagingData = Pager(PagingConfig(1), null) { dataSource() }.liveData + + private fun dataSource() = object : PagingSource() { + override fun getRefreshKey(state: PagingState): Long? = null + override suspend fun load(params: LoadParams): LoadResult { + when (params) { + is LoadParams.Refresh -> { + val list: List = snapshot.ifEmpty { dataProvider.loadInitial() } + dataList.value = list + return LoadResult.Page(list, list.firstOrNull()?.id(), list.lastOrNull()?.id()) + } + is LoadParams.Append -> { + val list: List = suspendCancellableCoroutine { continuation -> + dataProvider.loadAfter(params.key) { + continuation.resumeWithActive(it) + } + continuation.invokeOnCancellation { + continuation.resumeWithActive(emptyList()) + } + } + dataList.value = snapshot.toMutableList().also { it.addAll(list) } + return LoadResult.Page(list, list.firstOrNull()?.id(), list.lastOrNull()?.id()) + } + is LoadParams.Prepend -> { + val list: List = suspendCancellableCoroutine { continuation -> + dataProvider.loadBefore(params.key) { + continuation.resumeWithActive(it) + } + continuation.invokeOnCancellation { + continuation.resumeWithActive(emptyList()) + } + } + dataList.value = snapshot.toMutableList().also { it.addAll(0, list) } + return LoadResult.Page(list, list.firstOrNull()?.id(), list.lastOrNull()?.id()) + } + } + } + } + + fun redirect(adapter: ImageViewerAdapter, exclude: List, emptyCallback: () -> Unit) { + synchronized(this) { + val list = snapshot.asSequence() + val _dataList = list.filter { + !exclude.contains(it) + }.toList() + if (_dataList.isEmpty()) { + return Unit.also { emptyCallback() } + } + dataList.value = _dataList + + //找到应该定位到的targetItem + val last = exclude.lastOrNull() + val lastIndex = snapshot.indexOf(last) + val first = exclude.firstOrNull() + val firstIndex = exclude.indexOf(first) + val targetIndex = if (lastIndex != snapshot.size - 1) { + lastIndex + 1 + } else { + firstIndex - 1 + } + val target = snapshot[targetIndex] + dataProvider.exclude(exclude, target) + adapter.refresh() + } + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt new file mode 100644 index 0000000..f46f960 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Components.kt @@ -0,0 +1,56 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import com.remax.visualnovel.widget.imageviewer.core.DataProvider +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.OverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Transformer +import com.remax.visualnovel.widget.imageviewer.core.VHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.ViewerCallback + +object Components { + private var initialize = false + val working get() = initialize + private var imageLoader: ImageLoader? = null + private var dataProvider: DataProvider? = null + private var transformer: Transformer? = null + private var vhCustomizer: VHCustomizer? = null + private var overlayCustomizer: OverlayCustomizer? = null + private var viewerCallback: ViewerCallback? = null + + fun initialize(imageLoader: ImageLoader, dataProvider: DataProvider, transformer: Transformer) { + if (initialize) throw IllegalStateException() + Components.imageLoader = imageLoader + Components.dataProvider = dataProvider + Components.transformer = transformer + initialize = true + } + + fun setVHCustomizer(vhCustomizer: VHCustomizer?) { + Components.vhCustomizer = vhCustomizer + } + + fun setViewerCallback(viewerCallback: ViewerCallback?) { + Components.viewerCallback = viewerCallback + } + + fun setOverlayCustomizer(overlayCustomizer: OverlayCustomizer?) { + Components.overlayCustomizer = overlayCustomizer + } + + fun requireImageLoader() = imageLoader ?: object : ImageLoader {} + fun requireDataProvider() = dataProvider ?: object : DataProvider {} + fun requireTransformer() = transformer ?: object : Transformer {} + fun requireVHCustomizer() = vhCustomizer ?: object : VHCustomizer {} + fun requireViewerCallback() = viewerCallback ?: object : ViewerCallback {} + fun requireOverlayCustomizer() = overlayCustomizer ?: object : OverlayCustomizer {} + + fun release() { + initialize = false + imageLoader = null + dataProvider = null + transformer = null + vhCustomizer = null + viewerCallback = null + overlayCustomizer = null + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt new file mode 100644 index 0000000..e6744f1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/DataProvider.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +interface DataProvider { + fun loadInitial(): List = emptyList() + fun loadAfter(key: Long, callback: (List) -> Unit) {} + fun loadBefore(key: Long, callback: (List) -> Unit) {} + fun exclude(exclude: List, target: Photo) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt new file mode 100644 index 0000000..7ca3e29 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ImageLoader.kt @@ -0,0 +1,12 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView2 + +interface ImageLoader { + fun load(view: ImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) {} + fun load(subsamplingView: SubsamplingScaleImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) {} + fun load(exoVideoView: ExoVideoView2, data: Photo, viewHolder: RecyclerView.ViewHolder) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt new file mode 100644 index 0000000..8db8296 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/OverlayCustomizer.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.view.View +import android.view.ViewGroup + +interface OverlayCustomizer { + fun provideView(parent: ViewGroup): View? = null +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt new file mode 100644 index 0000000..32bf375 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Photo.kt @@ -0,0 +1,9 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType + +interface Photo { + fun id(): Long + fun itemType(): @ItemType.Type Int + fun extra(): Any = this +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt new file mode 100644 index 0000000..1944447 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/SimpleDataProvider.kt @@ -0,0 +1,40 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.os.Handler +import android.os.Looper + +import kotlin.math.min + +class SimpleDataProvider( + init: Photo, + list: List +) : DataProvider { + private var _init = init + private var _list = list + + val list = _list + + override fun loadInitial() = listOf(_init) + override fun loadAfter(key: Long, callback: (List) -> Unit) { + val idx = _list.indexOfFirst { it.id() == key } + val result: List = if (idx < 0) emptyList() + else _list.subList(idx + 1, _list.size) + Handler(Looper.getMainLooper()).post { + callback(result) + } + } + + override fun loadBefore(key: Long, callback: (List) -> Unit) { + val idx = _list.indexOfFirst { it.id() == key } + val result: List = if (idx < 0) emptyList() + else _list.subList(0, min(idx, _list.size)) + Handler(Looper.getMainLooper()).post { + callback(result) + } + } + + override fun exclude(exclude: List, target: Photo) { + _init = target + _list = _list.filter { !exclude.contains(it) } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt new file mode 100644 index 0000000..3d99b0c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/Transformer.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.widget.ImageView + +interface Transformer { + fun getView(key: Long): ImageView? = null +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt new file mode 100644 index 0000000..0177718 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/VHCustomizer.kt @@ -0,0 +1,8 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import androidx.recyclerview.widget.RecyclerView + +interface VHCustomizer { + fun initialize(type: Int, viewHolder: RecyclerView.ViewHolder) {} + fun bind(type: Int, data: Photo, position: Int, viewHolder: RecyclerView.ViewHolder) {} +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt new file mode 100644 index 0000000..846f35a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/core/ViewerCallback.kt @@ -0,0 +1,15 @@ +package com.remax.visualnovel.widget.imageviewer.core + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener + +interface ViewerCallback : ImageViewerAdapterListener { + override fun onInit(viewHolder: RecyclerView.ViewHolder, position: Int) {} + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) {} + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) {} + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) {} + fun onPageScrollStateChanged(state: Int) {} + fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} + fun onPageSelected(position: Int, item: Photo?,totalCount: Int, viewHolder: RecyclerView.ViewHolder) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt new file mode 100644 index 0000000..5dd830f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Config.kt @@ -0,0 +1,20 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.graphics.Color +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView + +object Config { + var DEBUG: Boolean = true + var OFFSCREEN_PAGE_LIMIT: Int = 1 + var VIEWER_ORIENTATION: Int = ViewPager2.ORIENTATION_HORIZONTAL + var VIEWER_BACKGROUND_COLOR: Int = Color.BLACK + var DURATION_TRANSITION: Long = 250L + var DURATION_BG: Long = 150L + var SWIPE_DISMISS: Boolean = true + var SWIPE_TOUCH_SLOP = 4f + var DISMISS_FRACTION: Float = 0.12f + var TRANSITION_OFFSET_Y = 0 + var VIEWER_FIRST_PAGE_SELECTED_DELAY = 300L + var VIDEO_SCALE_TYPE = ExoVideoView.SCALE_TYPE_FIT_CENTER +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt new file mode 100644 index 0000000..505a8eb --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/Extensions.kt @@ -0,0 +1,30 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.View +import android.view.ViewGroup +import androidx.core.view.forEach + +internal fun ViewGroup.findViewWithKeyTag(key: Int, tag: Any): View? { + forEach { + if (it.getTag(key) == tag) return it + if (it is ViewGroup) { + val result = it.findViewWithKeyTag(key, tag) + if (result != null) return result + } + } + return null +} + +internal val View.activity: Activity? + get() = getActivity(context) + +// https://stackoverflow.com/questions/9273218/is-it-always-safe-to-cast-context-to-activity-within-view/45364110 +private fun getActivity(context: Context?): Activity? { + if (context == null) return null + if (context is Activity) return context + if (context is ContextWrapper) return getActivity(context.baseContext) + return null +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt new file mode 100644 index 0000000..5529993 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionEndHelper.kt @@ -0,0 +1,197 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView +import androidx.core.view.ViewCompat +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.ChangeBounds +import androidx.transition.ChangeImageTransform +import androidx.transition.ChangeTransform +import androidx.transition.Transition +import androidx.transition.TransitionListenerAdapter +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.imageviewer.viewholders.PhotoViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.SubsamplingViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.VideoViewHolder +import kotlin.math.max + +object TransitionEndHelper { + val transitionAnimating get() = animating + private var animating = false + + fun end(fragment: DialogFragment, startView: View?, holder: RecyclerView.ViewHolder) { + beforeTransition(startView, holder) + val doTransition = { + TransitionManager.beginDelayedTransition(holder.itemView as ViewGroup, transitionSet().also { + it.addListener(object : TransitionListenerAdapter() { + override fun onTransitionStart(transition: Transition) { + animating = true + } + + override fun onTransitionEnd(transition: Transition) { + if (!animating) return + animating = false + fragment.dismissAllowingStateLoss() + } + }) + }) + transition(startView, holder) + } + holder.itemView.post(doTransition) + + fragment.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + fragment.lifecycle.removeObserver(this) + animating = false + holder.itemView.removeCallbacks(doTransition) + TransitionManager.endTransitions(holder.itemView as ViewGroup) + } + } + }) + } + + private fun beforeTransition(startView: View?, holder: RecyclerView.ViewHolder) { + when (holder) { + is VideoViewHolder -> { + holder.binding.imageView.translationX = holder.binding.videoView.translationX + holder.binding.imageView.translationY = holder.binding.videoView.translationY + holder.binding.imageView.scaleX = holder.binding.videoView.scaleX + holder.binding.imageView.scaleY = holder.binding.videoView.scaleY + holder.binding.imageView.visibility = View.VISIBLE + holder.binding.videoView.visibility = View.GONE + } + } + } + + private fun transition(startView: View?, holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + holder.binding.photoView.scaleType = (startView as? ImageView?)?.scaleType + ?: ImageView.ScaleType.FIT_CENTER + holder.binding.photoView.translationX = 0f + holder.binding.photoView.translationY = 0f + holder.binding.photoView.scaleX = if (startView != null) 1f else 2f + holder.binding.photoView.scaleY = if (startView != null) 1f else 2f + // holder.photoView.alpha = startView?.alpha ?: 0f + fade(holder, startView) + holder.binding.photoView.layoutParams = holder.binding.photoView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.translationX = 0f + holder.binding.subsamplingView.translationY = 0f + holder.binding.subsamplingView.scaleX = 2f + holder.binding.subsamplingView.scaleY = 2f + // holder.photoView.alpha = startView?.alpha ?: 0f + fade(holder) // https://github.com/davemorrissey/subsampling-scale-image-view/issues/313 + holder.binding.subsamplingView.layoutParams = holder.binding.subsamplingView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is VideoViewHolder -> { + holder.binding.imageView.translationX = 0f + holder.binding.imageView.translationY = 0f + holder.binding.imageView.scaleX = if (startView != null) 1f else 2f + holder.binding.imageView.scaleY = if (startView != null) 1f else 2f + // holder.photoView.alpha = startView?.alpha ?: 0f + fade(holder, startView) + holder.binding.videoView.pause() + holder.binding.imageView.layoutParams = holder.binding.imageView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + } + } + + private fun transitionSet(): Transition { + return TransitionSet().apply { + addTransition(ChangeBounds()) + addTransition(ChangeImageTransform()) + addTransition(ChangeTransform()) + // addTransition(Fade()) + duration = Config.DURATION_TRANSITION + interpolator = DecelerateInterpolator() + } + } + + private fun fade(holder: RecyclerView.ViewHolder, startView: View? = null) { + when (holder) { + is PhotoViewHolder -> { + if (startView != null) { + holder.binding.photoView.animate() + .setDuration(0) + .setStartDelay(max(Config.DURATION_TRANSITION - 20, 0)) + .alpha(0f).start() + } else { + holder.binding.photoView.animate().setDuration(Config.DURATION_TRANSITION) + .alpha(0f).start() + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.animate().setDuration(Config.DURATION_TRANSITION) + .alpha(0f).start() + } + + is VideoViewHolder -> { + if (startView != null) { + holder.binding.imageView.animate() + .setDuration(0) + .setStartDelay(max(Config.DURATION_TRANSITION - 20, 0)) + .alpha(0f).start() + } else { + holder.binding.imageView.animate().setDuration(Config.DURATION_TRANSITION) + .alpha(0f).start() + } + } + } + } + + private fun getLocationOnScreen(startView: View?, location: IntArray) { + startView?.getLocationOnScreen(location) + + if (location[0] == 0) { + location[0] = (startView?.getTag(R.id.viewer_start_view_location_0) as? Int) ?: 0 + } + if (location[1] == 0) { + location[1] = (startView?.getTag(R.id.viewer_start_view_location_1) as? Int) ?: 0 + } + + if (startView?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) { + location[0] = startView.context.resources.displayMetrics.widthPixels - location[0] - startView.width + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt new file mode 100644 index 0000000..d22fc5b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/TransitionStartHelper.kt @@ -0,0 +1,178 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView +import androidx.core.view.ViewCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.ChangeBounds +import androidx.transition.ChangeImageTransform +import androidx.transition.Transition +import androidx.transition.TransitionListenerAdapter +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.viewholders.PhotoViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.SubsamplingViewHolder +import com.remax.visualnovel.widget.imageviewer.viewholders.VideoViewHolder + +object TransitionStartHelper { + val transitionAnimating get() = animating + private var animating = false + + fun start(owner: LifecycleOwner, startView: View?, holder: RecyclerView.ViewHolder) { + beforeTransition(startView, holder) + val doTransition = { + TransitionManager.beginDelayedTransition(holder.itemView as ViewGroup, transitionSet().also { + it.addListener(object : TransitionListenerAdapter() { + override fun onTransitionStart(transition: Transition) { + animating = true + } + + override fun onTransitionEnd(transition: Transition) { + if (!animating) return + animating = false + afterTransition(holder) + } + }) + }) + transition(holder) + } + holder.itemView.postDelayed(doTransition, 50) + + owner.lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + owner.lifecycle.removeObserver(this) + animating = false + holder.itemView.removeCallbacks(doTransition) + TransitionManager.endTransitions(holder.itemView as ViewGroup) + } + } + }) + } + + private fun beforeTransition(startView: View?, holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + holder.binding.photoView.scaleType = (startView as? ImageView?)?.scaleType + ?: ImageView.ScaleType.FIT_CENTER + holder.binding.photoView.layoutParams = holder.binding.photoView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.layoutParams = holder.binding.subsamplingView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + + is VideoViewHolder -> { + holder.binding.imageView.layoutParams = holder.binding.imageView.layoutParams.apply { + width = startView?.width ?: width + height = startView?.height ?: height + val location = IntArray(2) + getLocationOnScreen(startView, location) + if (this is ViewGroup.MarginLayoutParams) { + marginStart = location[0] + topMargin = location[1] - Config.TRANSITION_OFFSET_Y + } + } + } + } + } + + private fun transition(holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + holder.binding.photoView.scaleType = ImageView.ScaleType.FIT_CENTER + holder.binding.photoView.layoutParams = holder.binding.photoView.layoutParams.apply { + width = MATCH_PARENT + height = MATCH_PARENT + if (this is ViewGroup.MarginLayoutParams) { + marginStart = 0 + topMargin = 0 + } + } + } + + is SubsamplingViewHolder -> { + holder.binding.subsamplingView.layoutParams = holder.binding.subsamplingView.layoutParams.apply { + width = MATCH_PARENT + height = MATCH_PARENT + if (this is ViewGroup.MarginLayoutParams) { + marginStart = 0 + topMargin = 0 + } + } + } + + is VideoViewHolder -> { + holder.binding.imageView.layoutParams = holder.binding.imageView.layoutParams.apply { + width = MATCH_PARENT + height = MATCH_PARENT + if (this is ViewGroup.MarginLayoutParams) { + marginStart = 0 + topMargin = 0 + } + } + } + } + } + + private fun transitionSet(): Transition { + return TransitionSet().apply { + addTransition(ChangeBounds()) + addTransition(ChangeImageTransform()) + // https://github.com/davemorrissey/subsampling-scale-image-view/issues/313 + duration = Config.DURATION_TRANSITION + interpolator = DecelerateInterpolator() + } + } + + private fun getLocationOnScreen(startView: View?, location: IntArray) { + startView?.getLocationOnScreen(location) + + if (location[0] == 0) { + location[0] = (startView?.getTag(R.id.viewer_start_view_location_0) as? Int) ?: 0 + } + if (location[1] == 0) { + location[1] = (startView?.getTag(R.id.viewer_start_view_location_1) as? Int) ?: 0 + } + + if (startView?.layoutDirection == ViewCompat.LAYOUT_DIRECTION_RTL) { + location[0] = startView.context.resources.displayMetrics.widthPixels - location[0] - startView.width + } + } + + private fun afterTransition(holder: RecyclerView.ViewHolder) { + when (holder) { + is PhotoViewHolder -> { + val photo = holder.binding.photoView.getTag(R.id.viewer_adapter_item_data) as Photo + requireImageLoader().load(holder.binding.photoView, photo, holder) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt new file mode 100644 index 0000000..87be394 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/utils/ViewModelUtils.kt @@ -0,0 +1,17 @@ +package com.remax.visualnovel.widget.imageviewer.utils + +import android.view.View +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.remax.visualnovel.widget.imageviewer.ImageViewerDialogFragment + +internal object ViewModelUtils { + inline fun provideViewModel(view: View): T? { + return (view.activity as? FragmentActivity?) + ?.supportFragmentManager + ?.fragments + ?.find { it is ImageViewerDialogFragment } + ?.let { ViewModelProvider(it).get(T::class.java) } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt new file mode 100644 index 0000000..cd00844 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyImageLoader.kt @@ -0,0 +1,225 @@ +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.lottie.LottieAnimationView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.target.ImageViewTarget +import com.bumptech.glide.request.transition.Transition +import com.remax.visualnovel.R +import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData +import com.remax.visualnovel.entity.model.MyImgData +import com.remax.visualnovel.entity.response.Album +import com.remax.visualnovel.entity.response.AppearanceImage +import com.remax.visualnovel.entity.response.ChatBackground +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.dialoglib.ScreenUtils +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.PhotoView2 +import timber.log.Timber + +class MyImageLoader : ImageLoader { + + private val screenWidth by lazy { + ScreenUtils.getScreenWidth() + } + + override fun load(view: ImageView, data: Photo, viewHolder: RecyclerView.ViewHolder) { + var isLock = false + val loadUrl = when (data) { + is Album -> { + isLock = data.imgUrl == null + data.imgUrl ?: data.img3 ?: return + } + + is AppearanceImage -> { + data.imageUrl + } + + is ChatBackground -> { + data.imgUrl + } + + is CustomAlbumData -> { + data.url + } + + else -> { + (data as? MyImgData?)?.url ?: return + } + } + loadImg(viewHolder.itemView, loadUrl, isLock) + } + + fun loadImg( + itemView: View, + url: String?, + isLock: Boolean + ) { + itemView.run { + val view = findViewById(R.id.photoView) + val mask = findViewById(R.id.mask) + val failIcon = findViewById(R.id.failIcon) + val lockViewGroup = findViewById(R.id.lockViewGroup) + val failTv = findViewById(R.id.failText) + val loadingView = findViewById(R.id.loadingView) + + setOnClick(mask) { + when (this) { + mask -> { + if (failIcon.isVisible) { + loadImg(itemView, url, isLock) + } + } + } + } + + Timber.d("大图加载 url: $url") +// val width = view.measuredWidth +// val height = view.measuredHeight +// Timber.d("大图加载 图片控件的宽高 $width $height ") +// val loadUrl = if (!isLock && width == screenWidth && height > 0) url.toS3Url( +// view.measuredWidth, +// view.measuredHeight, +// false +// ) else url +// Timber.d("大图加载裁剪 loadUrl: $loadUrl") + Glide.with(view).load(url) + .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) + .downsample(DownsampleStrategy.AT_LEAST) + .into(object : ImageViewTarget(view) { + override fun setResource(resource: Drawable?) { + + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + super.onResourceReady(resource, transition) + mask?.isVisible = false + failIcon?.isVisible = false + failTv?.isVisible = false + lockViewGroup?.isVisible = isLock + view.isVisible = true + val viewWidth = view.measuredWidth + val viewHeight = view.measuredHeight + val result = if (viewWidth == screenWidth) + cropDrawableFromTopCenter(view.context, resource, viewWidth, viewHeight) + else resource + view.setImageDrawable(result) + } + + override fun onLoadStarted(placeholder: Drawable?) { + super.onLoadStarted(placeholder) + mask?.isVisible = true + failIcon?.isVisible = false + failTv?.isVisible = false + view.isInvisible = true + lockViewGroup?.isVisible = false + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + super.onLoadFailed(errorDrawable) + mask?.isVisible = true + failIcon?.isVisible = true + failTv?.isVisible = true + view.isInvisible = true + lockViewGroup?.isVisible = false + } + }) + } + } + + + private fun cropDrawableFromTopCenter( + context: Context, + drawable: Drawable, + targetWidth: Int, + targetHeight: Int + ): Drawable { + // 将 Drawable 转换为 Bitmap + val bitmap = drawableToBitmap(drawable) ?: return drawable + Timber.d("大图加载 bitmap的宽高 ${bitmap.width} ${bitmap.height}") + Timber.d("大图加载 裁剪目标的宽高 $targetWidth $targetHeight") + + // 按比例调整目标尺寸 + val (adjustedWidth, adjustedHeight) = adjustTargetSize( + bitmap.width, + bitmap.height, + targetWidth, + targetHeight + ) + + Timber.d("大图加载 按比例调整 $adjustedWidth $adjustedHeight") + + // 计算裁剪的起始点(顶部中间) + val startX = (bitmap.width - adjustedWidth) / 2 + val startY = 0 // 从顶部开始 + + // 确保裁剪区域有效 + if (startX < 0 || adjustedWidth > bitmap.width || adjustedHeight > bitmap.height) { + return drawable // 或者抛出异常,视需求而定 + } + + // 裁剪 Bitmap + val croppedBitmap = Bitmap.createBitmap(bitmap, startX, startY, adjustedWidth, adjustedHeight) + + // 将裁剪后的 Bitmap 转换回 Drawable + return croppedBitmap.toDrawable(context.resources) + } + + // 辅助函数:将 Drawable 转换为 Bitmap + private fun drawableToBitmap(drawable: Drawable): Bitmap? { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + + // 如果 Drawable 不是 BitmapDrawable,创建一个 Bitmap 并绘制 + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + + // 辅助函数:按比例调整目标尺寸 + private fun adjustTargetSize( + bitmapWidth: Int, + bitmapHeight: Int, + targetWidth: Int, + targetHeight: Int + ): Pair { + // 如果目标尺寸小于等于 Bitmap 尺寸,直接返回 + if (targetWidth <= bitmapWidth && targetHeight <= bitmapHeight) { + return Pair(targetWidth, targetHeight) + } + + // 计算宽高比例 + val widthRatio = bitmapWidth.toFloat() / targetWidth + val heightRatio = bitmapHeight.toFloat() / targetHeight + + // 取较小的比例以确保裁剪区域不超过 Bitmap 尺寸 + val scaleRatio = minOf(widthRatio, heightRatio) + + // 按比例调整目标宽高 + val adjustedWidth = (targetWidth * scaleRatio).toInt() + val adjustedHeight = (targetHeight * scaleRatio).toInt() + + return Pair(adjustedWidth, adjustedHeight) + + } +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt new file mode 100644 index 0000000..ff79271 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyTransformer.kt @@ -0,0 +1,43 @@ +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.widget.ImageView +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.remax.visualnovel.extension.findActivityContext +import com.remax.visualnovel.widget.imageviewer.core.Transformer +import dagger.hilt.android.internal.ThreadUtil +import timber.log.Timber + +class SimpleTransformer : Transformer { + override fun getView(key: Long): ImageView? = ViewerTransitionHelper.provide(key) +} + +/** + * 维护Transition过渡动画的缩略图和大图之间的映射关系. + */ +object ViewerTransitionHelper { + + private val transition = HashMap() + + fun put(imageView: ImageView?, photoId: Long = imageView.hashCode().toLong()) { + require(ThreadUtil.isMainThread()) + (imageView?.context?.findActivityContext() as? LifecycleOwner)?.lifecycle?.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + transition.remove(imageView) + Timber.d("transitionHashMap onLifecycleObserverDestroy remove :${transition.size}") + } + }) + imageView?.let { + transition[imageView] = photoId + Timber.d("transitionHashMap put :${transition.size}") + } + } + + fun provide(photoId: Long): ImageView? { + transition.keys.forEach { + if (transition[it] == photoId) + return it + } + return null + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt new file mode 100644 index 0000000..3bd7ecc --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/MyViewerCustomizer.kt @@ -0,0 +1,555 @@ +/* +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.animation.Animator +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.remax.visualnovel.R +import com.remax.visualnovel.app.widget.tips.TipsMoreWindow +import com.remax.visualnovel.databinding.LayoutIndicatorBinding +import com.remax.visualnovel.entity.imbean.raw.CustomAlbumData +import com.remax.visualnovel.entity.request.ChatAlbum +import com.remax.visualnovel.entity.response.Album +import com.remax.visualnovel.entity.response.AppearanceImage +import com.remax.visualnovel.entity.response.ChatBackground +import com.remax.visualnovel.entity.response.Wallet +import com.remax.visualnovel.extension.changeLikedStatus +import com.remax.visualnovel.extension.formatPrice +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.showDoubleBtnDialog +import com.remax.visualnovel.extension.showSingleBtnDialog +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.ui.chat.ChatActivity +import com.remax.visualnovel.ui.chat.message.setting.background.ChatBackgroundActivity +import com.remax.visualnovel.ui.main.MainActivity +import com.remax.visualnovel.ui.main.create.CreateActivity +import com.remax.visualnovel.ui.main.create.image.ImageGenerateUtil +import com.remax.visualnovel.ui.main.foryou.ForYouFragment +import com.remax.visualnovel.ui.profile.CharacterProfileActivity +import com.remax.visualnovel.ui.profile.album.AlbumFragment +import com.remax.visualnovel.ui.profile.album.create.AlbumCreateActivity +import com.remax.visualnovel.ui.profile.album.um.UnlockMethodActivity +import com.remax.visualnovel.utils.EpalUtils +import com.remax.visualnovel.utils.StatusBarUtils +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.imageviewer.ImageViewerActionViewModel +import com.remax.visualnovel.widget.imageviewer.ImageViewerBuilder +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.ImageLoader +import com.remax.visualnovel.widget.imageviewer.core.OverlayCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.core.VHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.ViewerCallback +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.widgets.PhotoView2 +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView +import com.remax.visualnovel.widget.ui.lock.getLockLabel +import com.pengxr.modular.eventbus.generated.events.EventDefineOfAlbumEvents +import com.pengxr.modular.eventbus.generated.events.EventDefineOfWalletEvents +import io.reactivex.disposables.Disposable +import timber.log.Timber + +*/ +/** + * viewer 自定义业务&UI + *//* + +class MyViewerCustomizer : DefaultLifecycleObserver, VHCustomizer, OverlayCustomizer, ViewerCallback { + private var activity: FragmentActivity? = null + private var viewerActions: ImageViewerActionViewModel? = null + private var videoTask: Disposable? = null + private var lastVideoVH: RecyclerView.ViewHolder? = null + private var indicatorBinding: LayoutIndicatorBinding? = null + private var currentPosition = -1 + private var currData: Photo? = null + private var imageLoader: ImageLoader? = null + + private val albumLikeFailedObserver = androidx.lifecycle.Observer { + it?.let { + changeAlbumStatus(it, true) + } + } + + private val walletObserver = androidx.lifecycle.Observer { + changeWallet() + } + + */ +/** + * 对viewer进行自定义封装. 添加自定义指示器.video绑定.图片说明等自定义配置 + *//* + + fun process(activity: FragmentActivity, builder: ImageViewerBuilder) { + this.activity = activity + viewerActions = ViewModelProvider(activity)[ImageViewerActionViewModel::class.java] + activity.lifecycle.addObserver(this) + builder.setVHCustomizer(this) + builder.setOverlayCustomizer(this) + builder.setViewerCallback(this) + imageLoader = builder.imageLoader + + EventDefineOfAlbumEvents.albumLikeFailed().observe(activity, albumLikeFailedObserver) + EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().observe(activity, walletObserver) + } + + override fun initialize(type: Int, viewHolder: RecyclerView.ViewHolder) { + (viewHolder.itemView as? ViewGroup?)?.let { + it.addView(LayoutInflater.from(it.context).inflate(R.layout.item_photo_custom_layout, it, false)) + } + + when (type) { + ItemType.SUBSAMPLING -> { + viewHolder.itemView.findViewById(R.id.subsamplingView)?.run { + setOnClickListener { + viewerActions?.dismiss() + } + } + } + + ItemType.PHOTO -> { + viewHolder.itemView.run { + findViewById(R.id.photoView)?.run { + maximumScale = 8.0f +// postDelayed({ +// scale = 2.0f +// setScale(2.0f, false) +// }, 1000) + + setOnClickListener { viewerActions?.dismiss() } + } + } + } + + ItemType.VIDEO -> { + + } + } + } + + override fun bind(type: Int, data: Photo, position: Int, viewHolder: RecyclerView.ViewHolder) { + + } + + override fun provideView(parent: ViewGroup): View { + indicatorBinding = LayoutIndicatorBinding.inflate(LayoutInflater.from(parent.context), parent, false) + indicatorBinding?.run { + activity?.run { + indicatorLayout.setMargin(topMargin = StatusBarUtils.statusBarHeight) + val bottomMargin = (maxOf(StatusBarUtils.getNavBarHeight(), 20.dp) + 16.dp) + listOf( + albumLikeLayout, + previewSetView, + selectLayout, + albumLikeLayout, + chatBgSetLayout, + chatBgSelect + ).forEach { + it.setMargin(bottomMargin = bottomMargin) + } + } + lockViewGroup.setMyBalance() + + setOnClick(albumLikeLayout, navBack) { + when (this) { + navBack -> { + viewerActions?.dismiss() + } + + albumLikeLayout -> { + (currData as? Album)?.let { data -> + LoginManager.checkLogin { + EventDefineOfAlbumEvents.albumLike().post(data) + changeAlbumStatus(data) + } + } + } + } + } + } + return indicatorBinding!!.root + } + + override fun onDrag(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { +// viewHolder.itemView.findViewById(R.id.customizeDecor)?.findViewById(R.id.lockViewGroup)?.alpha = 0f + } + + override fun onRestore(viewHolder: RecyclerView.ViewHolder, view: View, fraction: Float) { +// viewHolder.itemView.findViewById(R.id.customizeDecor)?.findViewById(R.id.lockViewGroup)?.alpha = 1f + } + + override fun onRelease(viewHolder: RecyclerView.ViewHolder, view: View) { + viewHolder.itemView.findViewById(R.id.customizeDecor) + ?.animate()?.setDuration(Config.DURATION_TRANSITION)?.alpha(0f)?.start() + indicatorBinding?.indicatorDecor?.animate()?.setDuration(Config.DURATION_TRANSITION)?.alpha(0f)?.start() + release() + } + + @SuppressLint("SetTextI18n") + override fun onPageSelected(position: Int, item: Photo?, totalCount: Int, viewHolder: RecyclerView.ViewHolder) { + currData = item + + fun setTotalIndicator(total: Int) { + Timber.d("Image preview totalCount:$total") + indicatorBinding?.indicator?.text = (position + 1).toString() + "/" + total + indicatorBinding?.indicator?.isVisible = total > 1 + } + + when (item) { + //看相册 + is Album -> { + if (item.userId == null) { + item.userId = (activity as? CharacterProfileActivity)?.characterProfileViewModel?.character?.userId + } + val isMyself = LoginManager.isMyself(item.userId) + indicatorBinding?.also { + it.navMore.isVisible = isMyself + setOnClick(it.navMore) { + TipsMoreWindow().build( + context, + listOf(TipsMoreWindow.TipsMoreUIData(R.string.delete, R.string.icon_delete)), + clickCallback = { + (currData as? Album)?.let { data -> + activity?.run { + if (data.isDefault == true) { + showSingleBtnDialog(text = getString(R.string.dialog_delete_warning_default)) + } else { + this.showDoubleBtnDialog( + text = getString(R.string.dialog_delete_warning), + topBtnClick = { + val target = listOf(data) + viewerActions?.remove(target) + (activity as? CharacterProfileActivity)?.let { act -> + (act.binding.viewPager.adapter as? FragmentStateAdapter)?.let { adapter -> + (adapter.createFragment(1) as? AlbumFragment)?.deleteAlbum( + data + ) + } + } + }, + topBtnText = getString(R.string.delete) + ) + } + + } + } + }).showAsDropDown(it.navMore, 0, 8.dp) + + } + + //自己看自己的可修改 + with(it.previewSetView) { + isVisible = isMyself + if (isMyself) { + setBlur() + setUI(item) + (activity as? CharacterProfileActivity)?.let { act -> + setCallback( + { + if ((item.unlockPrice ?: 0L) > 0) { + act.showDoubleBtnDialog( + text = act.getString(R.string.dialog_set_default_warning), + topBtnClick = { + act.setDefaultAlbum(item) { + it.lockView.isVisible = false + setUI(item) + } + }, + topBtnText = act.getString(R.string.confirm) + ) + } else { + act.setDefaultAlbum(item) { + setUI(item) + } + } + } + ) { + UnlockMethodActivity.start(item, act) { unlockPrice -> + item.unlockPrice = unlockPrice + act.setAlbumPrice(item) + setUI(item) + } + } + } + } + } + + + //先关闭点赞动画 + if (it.albumLikeLottie.isAnimating) { + it.albumLikeLottie.cancelAnimation() + } + it.albumLikeLottie.isVisible = false + it.albumLikeIcon.changeLikedStatus(item.isLike()) + it.albumLikeNum.text = EpalUtils.formatNumberAutoSize(item.likedCount) + it.lockView.setLockLabel(item.userId.getLockLabel()) + + it.lockViewGroup.isVisible = false + it.lockView.isVisible = false + it.albumLikeLayout.isVisible = false + + when { + //公开图片 + item.isOpen() -> { + it.albumLikeLayout.isVisible = !isMyself + } + //私密图片 + else -> { + //解锁可看可点赞 自己不能给自己的点 + it.albumLikeLayout.isVisible = item.isUnLock() && !isMyself + it.lockView.isVisible = item.isUnLock() + //未解锁需要给钱 + it.lockViewGroup.isVisible = !item.isUnLock() + it.lockViewGroup.setPreviewUnlockInfo(item.unlockPrice ?: 0) { + //解锁当前这张照片 + when (activity) { + is CharacterProfileActivity -> { + (activity as? CharacterProfileActivity)?.let { act -> + (act.binding.viewPager.adapter as? FragmentStateAdapter)?.let { adapter -> + (adapter.createFragment(1) as? AlbumFragment)?.unlockAlbumImage( + item.albumId, + item.unlockPrice + ) { + it.lockViewGroup.isVisible = false + it.lockView.isVisible = true + it.albumLikeLayout.isVisible = true + (imageLoader as? MyImageLoader)?.loadImg( + viewHolder.itemView, + item.img3, + false + ) + } + } + } + } + + is MainActivity -> { + (activity as? MainActivity)?.let { act -> + (act.binding.viewPager2.adapter as? FragmentStateAdapter)?.let { adapter -> + (adapter.createFragment(0) as? ForYouFragment)?.unlockAlbumImage( + ChatAlbum(item.aiId ?: "", item.albumId, item.unlockPrice) + ) { + it.lockViewGroup.isVisible = false + it.lockView.isVisible = true + it.albumLikeLayout.isVisible = true + (imageLoader as? MyImageLoader)?.loadImg( + viewHolder.itemView, + item.img3, + false + ) + } + } + } + } + } + } + } + } + } + } + + //AI创作选择 + is AppearanceImage -> { + indicatorBinding?.also { + when { + activity is AlbumCreateActivity && ((activity as? AlbumCreateActivity)?.generateScene == ImageGenerateUtil.ALBUM) -> { + it.albumBottomLayout.isVisible = true + it.albumCheckBox.viewChecked(item.select) + fun setPrice() { + it.albumPriceView.setPrice(formatPrice(item.unlockPrice)) + it.albumPriceView.isVisible = item.unlockPrice > 0 + it.albumFree.isVisible = !it.albumPriceView.isVisible + } + setPrice() + setOnClick(it.albumPrice, it.albumSelectLayout) { + when (this) { + it.albumPrice -> { + (activity as? AlbumCreateActivity)?.let { act -> + act.imageGenerateUtil.setPrice(item, act) { + setPrice() + } + } + } + */ +/** + * 相册单选 + *//* + + it.albumSelectLayout -> { + if (!item.select) { + it.albumCheckBox.viewChecked(true) + (activity as? AlbumCreateActivity)?.imageGenerateUtil?.setSelect(item) + } + } + } + } + } + + */ +/** + * 其他是单选 + *//* + + else -> { + it.selectLayout.isVisible = true + it.checkBox.viewChecked(item.select) + setOnClick(it.selectLayout) { + if (!item.select) { + it.checkBox.viewChecked(true) + it.checkBox.post { + //创建/编辑AI + (activity as? CreateActivity)?.imageGenerateUtil?.setSelect(item) + //IM聊天创作背景 + (activity as? AlbumCreateActivity)?.imageGenerateUtil?.setSelect(item) + } + } + } + } + } + + + } + } + + //聊天chat背景图 + is ChatBackground -> { + indicatorBinding?.also { + it.navMore.isVisible = !item.isDefault + it.chatBgSelect.isVisible = true + it.chatBgSelect.isEnabled = item.imgUrl != (activity as? ChatBackgroundActivity)?.backgroundImg + setOnClick(it.chatBgSelect, it.navMore) { + when (this) { + it.chatBgSelect -> { + (activity as? ChatBackgroundActivity)?.let { act -> + act.setBackground(item) { + viewerActions?.dismiss() + } + } + } + + it.navMore -> { + TipsMoreWindow().build( + context, + listOf(TipsMoreWindow.TipsMoreUIData(R.string.delete, R.string.icon_delete)), + clickCallback = { + (activity as? ChatBackgroundActivity)?.let { act -> + act.removeBackground(item) { + val target = listOf(item) + viewerActions?.remove(target) + } + } + }).showAsDropDown(it.navMore, 0, 8.dp) + + } + } + + } + } + } + + //IM列表图片 + is CustomAlbumData -> { + indicatorBinding?.also { + it.lockViewGroup.isVisible = item.unlockPrice != 0L + it.lockViewGroup.setPreviewUnlockInfo(item.unlockPrice) { + //解锁当前这张照片 + (activity as? ChatActivity)?.let { act -> + act.unlockAlbumImage(item.albumId, item.unlockPrice, item.messageServerId) { album -> + it.lockViewGroup.isVisible = false + item.unlockPrice = 0L + item.url = album?.img3 ?: "" + (imageLoader as? MyImageLoader)?.loadImg(viewHolder.itemView, item.url, false) + } + } + } + } + } + } + + currentPosition = position + setTotalIndicator(totalCount) + } + + override fun onResume(owner: LifecycleOwner) { + lastVideoVH?.itemView?.findViewById(R.id.videoView)?.resume() + } + + override fun onPause(owner: LifecycleOwner) { + lastVideoVH?.itemView?.findViewById(R.id.videoView)?.pause() + } + + override fun onDestroy(owner: LifecycleOwner) { + lastVideoVH?.itemView?.findViewById(R.id.videoView)?.release() + videoTask?.dispose() + videoTask = null + } + + private fun changeWallet() { + indicatorBinding?.lockViewGroup?.setMyBalance() + } + + private fun changeAlbumStatus(data: Album, fromFailed: Boolean = false) { + indicatorBinding?.run { + if (data.isLike()) { + if (fromFailed) { + albumLikeLottie.isVisible = false + albumLikeIcon.changeLikedStatus(true) + } else { + albumLikeIcon.isInvisible = true + albumLikeLottie.run { + isVisible = true + progress = 0f + addAnimatorListener(object : Animator.AnimatorListener { + override fun onAnimationStart(p0: Animator) { + isEnabled = false + } + + override fun onAnimationEnd(p0: Animator) { + isEnabled = true + progress = 1f + } + + override fun onAnimationCancel(p0: Animator) { + + } + + override fun onAnimationRepeat(p0: Animator) { + + } + }) + playAnimation() + } + } + } else { + albumLikeLottie.isVisible = false + albumLikeIcon.changeLikedStatus(false) + } + albumLikeNum.text = EpalUtils.formatNumberAutoSize(data.likedCount) + } + } + + private fun release() { + activity?.lifecycle?.removeObserver(this) + activity = null + videoTask?.dispose() + videoTask = null + lastVideoVH = null + indicatorBinding = null + EventDefineOfAlbumEvents.albumLikeFailed().removeObserver(albumLikeFailedObserver) + EventDefineOfWalletEvents.buffBalanceUpdateSucceeded().removeObserver(walletObserver) + } + +} + + +*/ diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt new file mode 100644 index 0000000..5733509 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewer/ViewerHelper.kt @@ -0,0 +1,61 @@ +package com.remax.visualnovel.widget.imageviewer.viewer + +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.R +import com.remax.visualnovel.entity.model.MyImgData +import com.remax.visualnovel.widget.imageviewer.ImageViewerBuilder +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.core.SimpleDataProvider +import com.remax.visualnovel.widget.imageviewer.utils.Config + + +/** + * viewer的自定义初始化方案 + */ +object ViewerHelper { + fun openViewer( + context: FragmentActivity, + datas: List, + currPosition: Int = 0 + ) { + if (datas.isNotEmpty() && currPosition < datas.size) { + val newList = arrayListOf().apply { addAll(datas) } + provideImageViewerBuilder(context, newList[currPosition], newList).show() + } + } + + fun openViewerOne(context: FragmentActivity, imageView: View?, imageUrl: String?) { + openViewerOne(context, MyImgData(imageView?.hashCode()?.toLong() ?: 0L, imageUrl)) + } + + fun openViewerOne(context: FragmentActivity, data: Photo) { + val datas = arrayListOf(data) + provideImageViewerBuilder(context, datas[0], datas).show() + } + + private fun provideImageViewerBuilder( + context: FragmentActivity, + clickedData: Photo, + myImgDatas: List + ): ImageViewerBuilder { + // viewer 构造的基本元素 + val builder = ImageViewerBuilder( + context = context, + dataProvider = SimpleDataProvider(clickedData, myImgDatas), + imageLoader = MyImageLoader(), + transformer = SimpleTransformer() + ) +// Config.TRANSITION_OFFSET_Y = StatusBarUtils.statusBarHeight + Config.VIEWER_BACKGROUND_COLOR = ContextCompat.getColor(context, R.color.black) + Config.DEBUG = BuildConfig.DEBUG + + //MyViewerCustomizer().process(context, builder) // 添加自定义业务逻辑和UI处理 + + return builder + } +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt new file mode 100644 index 0000000..054c92a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/PhotoViewHolder.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ItemImageviewerPhotoBinding +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Components.requireVHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.PhotoView2 + +class PhotoViewHolder( + parent: ViewGroup, + callback: ImageViewerAdapterListener, + val binding: ItemImageviewerPhotoBinding = + ItemImageviewerPhotoBinding.inflate(LayoutInflater.from(parent.context), parent, false) +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.photoView.setListener(object : PhotoView2.Listener { + override fun onDrag(view: PhotoView2, fraction: Float) = callback.onDrag(this@PhotoViewHolder, view, fraction) + override fun onRestore(view: PhotoView2, fraction: Float) = callback.onRestore(this@PhotoViewHolder, view, fraction) + override fun onRelease(view: PhotoView2) = callback.onRelease(this@PhotoViewHolder, view) + }) + requireVHCustomizer().initialize(ItemType.PHOTO, this) + } + + fun bind(item: Photo, position: Int) { + binding.photoView.setTag(R.id.viewer_adapter_item_key, item.id()) + binding.photoView.setTag(R.id.viewer_adapter_item_data, item) + binding.photoView.setTag(R.id.viewer_adapter_item_holder, this) + requireVHCustomizer().bind(ItemType.PHOTO, item, position,this) + requireImageLoader().load(binding.photoView, item, this) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt new file mode 100644 index 0000000..109c93f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/SubsamplingViewHolder.kt @@ -0,0 +1,41 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ItemImageviewerSubsamplingBinding +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Components.requireVHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.SubsamplingScaleImageView2 + +class SubsamplingViewHolder( + parent: ViewGroup, + callback: ImageViewerAdapterListener, + val binding: ItemImageviewerSubsamplingBinding = + ItemImageviewerSubsamplingBinding.inflate(LayoutInflater.from(parent.context), parent, false) +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.subsamplingView.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_START) + binding.subsamplingView.setListener(object : SubsamplingScaleImageView2.Listener { + override fun onDrag(view: SubsamplingScaleImageView2, fraction: Float) = callback.onDrag(this@SubsamplingViewHolder, view, fraction) + override fun onRestore(view: SubsamplingScaleImageView2, fraction: Float) = callback.onRestore(this@SubsamplingViewHolder, view, fraction) + override fun onRelease(view: SubsamplingScaleImageView2) = callback.onRelease(this@SubsamplingViewHolder, view) + }) + requireVHCustomizer().initialize(ItemType.SUBSAMPLING, this) + } + + fun bind(item: Photo, position: Int) { + binding.subsamplingView.setTag(R.id.viewer_adapter_item_key, item.id()) + binding.subsamplingView.setTag(R.id.viewer_adapter_item_data, item) + binding.subsamplingView.setTag(R.id.viewer_adapter_item_holder, this) + requireVHCustomizer().bind(ItemType.SUBSAMPLING, item, position,this) + requireImageLoader().load(binding.subsamplingView, item, this) + } +} + + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt new file mode 100644 index 0000000..1168d51 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/UnknownViewHolder.kt @@ -0,0 +1,6 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class UnknownViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt new file mode 100644 index 0000000..af1cb7f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/viewholders/VideoViewHolder.kt @@ -0,0 +1,37 @@ +package com.remax.visualnovel.widget.imageviewer.viewholders + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ItemImageviewerVideoBinding +import com.remax.visualnovel.widget.imageviewer.ImageViewerAdapterListener +import com.remax.visualnovel.widget.imageviewer.adapter.ItemType +import com.remax.visualnovel.widget.imageviewer.core.Components.requireImageLoader +import com.remax.visualnovel.widget.imageviewer.core.Components.requireVHCustomizer +import com.remax.visualnovel.widget.imageviewer.core.Photo +import com.remax.visualnovel.widget.imageviewer.widgets.video.ExoVideoView2 + +class VideoViewHolder( + parent: ViewGroup, + callback: ImageViewerAdapterListener, + val binding: ItemImageviewerVideoBinding = + ItemImageviewerVideoBinding.inflate(LayoutInflater.from(parent.context), parent, false) +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.videoView.addListener(object : ExoVideoView2.Listener { + override fun onDrag(view: ExoVideoView2, fraction: Float) = callback.onDrag(this@VideoViewHolder, view, fraction) + override fun onRestore(view: ExoVideoView2, fraction: Float) = callback.onRestore(this@VideoViewHolder, view, fraction) + override fun onRelease(view: ExoVideoView2) = callback.onRelease(this@VideoViewHolder, view) + }) + requireVHCustomizer().initialize(ItemType.VIDEO, this) + } + + fun bind(item: Photo, position: Int) { + binding.videoView.setTag(R.id.viewer_adapter_item_key, item.id()) + binding.videoView.setTag(R.id.viewer_adapter_item_data, item) + binding.videoView.setTag(R.id.viewer_adapter_item_holder, this) + requireVHCustomizer().bind(ItemType.VIDEO, item,position, this) + requireImageLoader().load(binding.videoView, item, this) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt new file mode 100644 index 0000000..fc43bca --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/BackgroundView.kt @@ -0,0 +1,46 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.view.animation.DecelerateInterpolator +import androidx.constraintlayout.widget.ConstraintLayout +import com.remax.visualnovel.widget.imageviewer.utils.Config.DURATION_BG + +class BackgroundView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { + private val argbEvaluator by lazy { ArgbEvaluator() } + private var bgColor = Color.TRANSPARENT + private var animator: ValueAnimator? = null + + fun changeToBackgroundColor(targetColor: Int) { + animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = DURATION_BG + interpolator = DecelerateInterpolator() + val start = bgColor + addUpdateListener { + val fraction = it.animatedValue as Float + updateBackgroundColor(fraction, start, targetColor) + } + } + animator?.start() + } + + fun updateBackgroundColor(fraction: Float, startValue: Int, endValue: Int) { + setBackgroundColor(argbEvaluator.evaluate(fraction, startValue, endValue) as Int) + } + + override fun setBackgroundColor(color: Int) { + super.setBackgroundColor(color) + bgColor = color + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animator?.cancel() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt new file mode 100644 index 0000000..f3b16f3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/InterceptLayout.kt @@ -0,0 +1,13 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.widget.FrameLayout +import com.remax.visualnovel.widget.imageviewer.utils.TransitionEndHelper +import com.remax.visualnovel.widget.imageviewer.utils.TransitionStartHelper + +class InterceptLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) + : FrameLayout(context, attrs, defStyleAttr) { + override fun onInterceptTouchEvent(ev: MotionEvent?) = TransitionStartHelper.transitionAnimating || TransitionEndHelper.transitionAnimating +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt new file mode 100644 index 0000000..de46ccd --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/PhotoView2.kt @@ -0,0 +1,123 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.widget.imageviewer.ImageViewerViewModel +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.utils.ViewModelUtils.provideViewModel +import com.github.chrisbanes.photoview.PhotoView +import timber.log.Timber +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class PhotoView2 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : PhotoView(context, attrs, defStyleAttr) { + interface Listener { + fun onDrag(view: PhotoView2, fraction: Float) + fun onRestore(view: PhotoView2, fraction: Float) + fun onRelease(view: PhotoView2) + } + + private val viewModel by lazy { provideViewModel(this) } + + private val scaledTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop * Config.SWIPE_TOUCH_SLOP } + private val dismissEdge by lazy { height * Config.DISMISS_FRACTION } + private var singleTouch = true + private var fakeDragOffset = 0f + private var lastX = 0f + private var lastY = 0f + private var listener: Listener? = null + + fun setListener(listener: Listener?) { + this.listener = listener + } + + override fun onDraw(canvas: Canvas) { + try { + super.onDraw(canvas) + } catch (e: Exception) { + Timber.e("PhotoView2 onDraw Exception" + e.localizedMessage) + } + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (Config.SWIPE_DISMISS && Config.VIEWER_ORIENTATION == ViewPager2.ORIENTATION_HORIZONTAL) { + handleDispatchTouchEvent(event) + } + return super.dispatchTouchEvent(event) + } + + private fun handleDispatchTouchEvent(event: MotionEvent?) { + when (event?.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + setSingleTouch(false) + animate().translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> up() + MotionEvent.ACTION_MOVE -> { + Timber.d("handleDispatchTouchEvent singleTouch:$singleTouch") + if (singleTouch) { +// if (singleTouch && scale == 1f) { + if (lastX == 0f) lastX = event.rawX + if (lastY == 0f) lastY = event.rawY + val offsetX = event.rawX - lastX + val offsetY = event.rawY - lastY + fakeDrag(offsetX, offsetY) + } + } + } + } + + private fun fakeDrag(offsetX: Float, offsetY: Float) { + if (fakeDragOffset == 0f) { + if (offsetY > scaledTouchSlop) fakeDragOffset = scaledTouchSlop + else if (offsetY < -scaledTouchSlop) fakeDragOffset = -scaledTouchSlop + } + if (fakeDragOffset != 0f) { + val fixedOffsetY = offsetY - fakeDragOffset + setAllowParentInterceptOnEdge(false) + val fraction = abs(max(-1f, min(1f, fixedOffsetY / height))) + val fakeScale = 1 - min(0.4f, fraction) + scaleX = fakeScale + scaleY = fakeScale + translationY = fixedOffsetY + translationX = offsetX / 2 + listener?.onDrag(this, fraction) + } + } + + private fun up() { + setAllowParentInterceptOnEdge(true) + setSingleTouch(true) + fakeDragOffset = 0f + lastX = 0f + lastY = 0f + + if (abs(translationY) > dismissEdge) { + listener?.onRelease(this) + } else { + val offsetY = translationY + val fraction = min(1f, offsetY / height) + listener?.onRestore(this, fraction) + + animate().translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + } + + private fun setSingleTouch(value: Boolean) { + singleTouch = value + viewModel?.setViewerUserInputEnabled(value) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animate().cancel() + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt new file mode 100644 index 0000000..9d25ec0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/SubsamplingScaleImageView2.kt @@ -0,0 +1,135 @@ +package com.remax.visualnovel.widget.imageviewer.widgets + +import android.content.Context +import android.graphics.PointF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.viewpager2.widget.ViewPager2 +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import com.remax.visualnovel.widget.imageviewer.ImageViewerViewModel +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.remax.visualnovel.widget.imageviewer.utils.ViewModelUtils.provideViewModel +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class SubsamplingScaleImageView2 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) + : SubsamplingScaleImageView(context, attrs) { + interface Listener { + fun onDrag(view: SubsamplingScaleImageView2, fraction: Float) + fun onRestore(view: SubsamplingScaleImageView2, fraction: Float) + fun onRelease(view: SubsamplingScaleImageView2) + } + + private val viewModel by lazy { provideViewModel(this) } + private var initCenter: PointF? = null + private var changedCenter: PointF? = null + private var initScale: Float? = null + private val scaledTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop * Config.SWIPE_TOUCH_SLOP } + private val dismissEdge by lazy { height * Config.DISMISS_FRACTION } + private var imageLoaded = false + private var singleTouch = true + private var fakeDragOffset = 0f + private var lastX = 0f + private var lastY = 0f + private var listener: Listener? = null + + init { + setOnStateChangedListener(object : OnStateChangedListener { + override fun onScaleChanged(newScale: Float, origin: Int) = Unit + override fun onCenterChanged(newCenter: PointF?, origin: Int) { + changedCenter = newCenter + } + }) + setOnImageEventListener(object : DefaultOnImageEventListener() { + override fun onImageLoaded() { imageLoaded = true } + }) + } + + fun setListener(listener: Listener?) { + this.listener = listener + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (Config.SWIPE_DISMISS && Config.VIEWER_ORIENTATION == ViewPager2.ORIENTATION_HORIZONTAL) { + handleDispatchTouchEvent(event) + } + return super.dispatchTouchEvent(event) + } + + private fun handleDispatchTouchEvent(event: MotionEvent?) { + if (!imageLoaded) return + if (initScale == null) { + initScale = scale + initCenter = center + changedCenter = center + } + when (event?.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + setSingleTouch(false) + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> up() + MotionEvent.ACTION_MOVE -> { + if (singleTouch && scale == initScale && (changedCenter?.y ?: initCenter?.y) == (initCenter?.y ?: 0f)) { + if (lastX == 0f) lastX = event.rawX + if (lastY == 0f) lastY = event.rawY + val offsetX = event.rawX - lastX + val offsetY = event.rawY - lastY + fakeDrag(offsetX, offsetY) + } + } + } + } + + private fun fakeDrag(offsetX: Float, offsetY: Float) { + if (fakeDragOffset == 0f) { + if (offsetY > scaledTouchSlop) fakeDragOffset = scaledTouchSlop + else if (offsetY < -scaledTouchSlop) fakeDragOffset = -scaledTouchSlop + } + if (fakeDragOffset != 0f) { + val fixedOffsetY = offsetY - fakeDragOffset + parent?.requestDisallowInterceptTouchEvent(true) + val fraction = abs(max(-1f, min(1f, fixedOffsetY / height))) + val fakeScale = 1 - min(0.4f, fraction) + scaleX = fakeScale + scaleY = fakeScale + translationY = fixedOffsetY + translationX = offsetX / 2 + listener?.onDrag(this, fraction) + } + } + + private fun up() { + parent?.requestDisallowInterceptTouchEvent(false) + setSingleTouch(true) + fakeDragOffset = 0f + lastX = 0f + lastY = 0f + + if (abs(translationY) > dismissEdge) { + listener?.onRelease(this) + } else { + val offsetY = translationY + val fraction = min(1f, offsetY / height) + listener?.onRestore(this, fraction) + + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + } + + private fun setSingleTouch(value: Boolean) { + singleTouch = value + viewModel?.setViewerUserInputEnabled(value) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animate().cancel() + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt new file mode 100644 index 0000000..8fe18e4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView.kt @@ -0,0 +1,182 @@ +package com.remax.visualnovel.widget.imageviewer.widgets.video + +import android.content.Context +import android.util.AttributeSet +import android.view.TextureView +import com.remax.visualnovel.widget.imageviewer.utils.Config +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.analytics.AnalyticsListener +import com.google.android.exoplayer2.util.EventLogger +import com.google.android.exoplayer2.video.VideoSize +import kotlin.math.max +import kotlin.math.min + +open class ExoVideoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr) { + interface VideoRenderedListener { + fun onRendered(view: ExoVideoView) + } + + interface MediaItemProvider { + fun provide(playUrl: String): List? + } + + companion object { + const val SCALE_TYPE_FIT_XY = 0 + const val SCALE_TYPE_FIT_CENTER = 1 + const val SCALE_TYPE_CENTER_CROP = 2 + } + + private val logger by lazy { EventLogger(null) } + private var exoPlayer: SimpleExoPlayer? = null + private var videoRenderedCallback: VideoRenderedListener? = null + private val listeners = mutableListOf() + private var playUrl: String? = null + protected var prepared = false + private var st = Config.VIDEO_SCALE_TYPE + val scaleType get() = st + private var ar = true + val autoRelease get() = ar + + fun prepare(url: String) { + playUrl = url + } + + fun resume( + provider: MediaItemProvider? = null + ) { + val url = playUrl ?: return + if (exoPlayer == null) { + prepared = false + alpha = 0f + newExoPlayer() + exoPlayer?.setMediaItems(provider?.provide(url) ?: listOf(MediaItem.fromUri(url))) + exoPlayer?.prepare() + } + exoPlayer?.playWhenReady = true + } + + fun pause() { + exoPlayer?.playWhenReady = false + } + + fun reset() { + exoPlayer?.seekTo(0) + exoPlayer?.playWhenReady = false + } + + fun release() { + val player = exoPlayer ?: return + player.playWhenReady = false + player.setVideoTextureView(null) + player.removeListener(videoListener) + player.removeAnalyticsListener(logger) + listeners.toList().forEach { player.removeAnalyticsListener(it) } + player.release() + exoPlayer = null + } + + fun setScaleType(scaleType: Int) { + st = scaleType + } + + fun setAutoRelease(autoRelease: Boolean) { + ar = autoRelease + } + + fun setVideoRenderedCallback(listener: VideoRenderedListener?) { + videoRenderedCallback = listener + } + + fun addAnalyticsListener(analyticsListener: AnalyticsListener) { + if (!listeners.contains(analyticsListener)) { + listeners.add(analyticsListener) + } + } + + fun player( + provider: MediaItemProvider? = null + ): ExoPlayer? { + val url = playUrl ?: return null + if (exoPlayer == null) { + prepared = false + alpha = 0f + newExoPlayer() + exoPlayer?.setMediaItems(provider?.provide(url) ?: listOf(MediaItem.fromUri(url))) + exoPlayer?.prepare() + } + return exoPlayer + } + + private fun newExoPlayer(): ExoPlayer { + release() + return SimpleExoPlayer.Builder(context).build().also { + it.setVideoTextureView(this) + it.addListener(videoListener) + if (Config.DEBUG) it.addAnalyticsListener(logger) + listeners.toList().forEach { userListener -> it.addAnalyticsListener(userListener) } + exoPlayer = it + } + } + + private val videoListener = object : Player.Listener { + override fun onVideoSizeChanged( + videoSize: VideoSize + ) { + updateTextureViewSize(videoSize.width, videoSize.height) + } + } + + private fun updateTextureViewSize(videoWidth: Int, videoHeight: Int) { + when (st) { + SCALE_TYPE_FIT_CENTER -> fitCenter(videoWidth, videoHeight) + SCALE_TYPE_CENTER_CROP -> centerCrop(videoWidth, videoHeight) + SCALE_TYPE_FIT_XY -> fitXY(videoWidth, videoHeight) + } + invalidate() + alpha = 1f + videoRenderedCallback?.onRendered(this) + prepared = true + } + + private fun fitCenter(videoWidth: Int, videoHeight: Int) { + val sx = width * 1f / videoWidth + val sy = height * 1f / videoHeight + val matrix = android.graphics.Matrix() + matrix.postScale(videoWidth * 1f / width, videoHeight * 1f / height) + matrix.postScale(min(sx, sy), min(sx, sy)) + matrix.postTranslate( + if (sx > sy) (width - videoWidth * sy) / 2 else 0f, + if (sx > sy) 0f else (height - videoHeight * sx) / 2 + ) + setTransform(matrix) + } + + private fun centerCrop(videoWidth: Int, videoHeight: Int) { + val sx = width * 1f / videoWidth + val sy = height * 1f / videoHeight + val matrix = android.graphics.Matrix() + matrix.postScale(videoWidth * 1f / width, videoHeight * 1f / height) + matrix.postScale(max(sx, sy), max(sx, sy)) + matrix.postTranslate( + if (sx < sy) (width - videoWidth * sy) / 2 else 0f, + if (sx < sy) 0f else (height - videoHeight * sx) / 2 + ) + setTransform(matrix) + } + + private fun fitXY(videoWidth: Int, videoHeight: Int) { + // default + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + if (autoRelease) release() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt new file mode 100644 index 0000000..50b32de --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/imageviewer/widgets/video/ExoVideoView2.kt @@ -0,0 +1,147 @@ +package com.remax.visualnovel.widget.imageviewer.widgets.video + +import android.content.Context +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.viewpager2.widget.ViewPager2 +import com.remax.visualnovel.widget.imageviewer.utils.Config +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class ExoVideoView2 @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ExoVideoView(context, attrs, defStyleAttr), + View.OnTouchListener { + + interface Listener { + fun onDrag(view: ExoVideoView2, fraction: Float) + fun onRestore(view: ExoVideoView2, fraction: Float) + fun onRelease(view: ExoVideoView2) + } + + private val scaledTouchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop * Config.SWIPE_TOUCH_SLOP } + private val dismissEdge by lazy { height * Config.DISMISS_FRACTION } + private var singleTouch = true + private var fakeDragOffset = 0f + private var lastX = 0f + private var lastY = 0f + private val listeners = mutableListOf() + private var clickListener: OnClickListener? = null + private var longClickListener: OnLongClickListener? = null + + init { + setOnTouchListener(this) + } + + fun addListener(listener: Listener) { + if (!listeners.contains(listener)) { + listeners.add(listener) + } + } + + override fun setOnClickListener(listener: OnClickListener?) { + clickListener = listener + } + + override fun setOnLongClickListener(listener: OnLongClickListener?) { + longClickListener = listener + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (Config.SWIPE_DISMISS && Config.VIEWER_ORIENTATION == ViewPager2.ORIENTATION_HORIZONTAL) { + handleDispatchTouchEvent(event) + } + return super.dispatchTouchEvent(event) + } + + override fun onTouch(v: View?, event: MotionEvent): Boolean { + gestureDetector.onTouchEvent(event) + return true + } + + private fun handleDispatchTouchEvent(event: MotionEvent?) { + if (!prepared) return + + when (event?.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + singleTouch = false + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> up() + MotionEvent.ACTION_MOVE -> { + if (singleTouch) { + if (lastX == 0f) lastX = event.rawX + if (lastY == 0f) lastY = event.rawY + val offsetX = event.rawX - lastX + val offsetY = event.rawY - lastY + fakeDrag(offsetX, offsetY) + } + } + } + } + + private fun fakeDrag(offsetX: Float, offsetY: Float) { + if (fakeDragOffset == 0f) { + if (offsetY > scaledTouchSlop) fakeDragOffset = scaledTouchSlop + else if (offsetY < -scaledTouchSlop) fakeDragOffset = -scaledTouchSlop + } + if (fakeDragOffset != 0f) { + val fixedOffsetY = offsetY - fakeDragOffset + parent?.requestDisallowInterceptTouchEvent(true) + val fraction = abs(max(-1f, min(1f, fixedOffsetY / height))) + val fakeScale = 1 - min(0.4f, fraction) + scaleX = fakeScale + scaleY = fakeScale + translationY = fixedOffsetY + translationX = offsetX / 2 + listeners.toList().forEach { it.onDrag(this, fraction) } + } + } + + private fun up() { + parent?.requestDisallowInterceptTouchEvent(false) + singleTouch = true + fakeDragOffset = 0f + lastX = 0f + lastY = 0f + + if (abs(translationY) > dismissEdge) { + listeners.toList().forEach { it.onRelease(this) } + } else { + val offsetY = translationY + val fraction = min(1f, offsetY / height) + listeners.toList().forEach { it.onRestore(this, fraction) } + + animate() + .translationX(0f).translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(200).start() + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + animate().cancel() + } + + private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { + // forward long click listener + override fun onLongPress(e: MotionEvent) { + longClickListener?.onLongClick(this@ExoVideoView2) + } + }).apply { + setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + clickListener?.onClick(this@ExoVideoView2) + return true + } + + override fun onDoubleTapEvent(e: MotionEvent) = false + override fun onDoubleTap(e: MotionEvent): Boolean = true + }) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt new file mode 100644 index 0000000..ee17305 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/indicator/ViewPager2Helper.kt @@ -0,0 +1,27 @@ +package com.remax.visualnovel.widget.indicator + +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import net.lucode.hackware.magicindicator.MagicIndicator + +object ViewPager2Helper { + fun bind(magicIndicator: MagicIndicator, viewPager: ViewPager2, onPageSelected: ((Int) -> Unit)? = null) { + viewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() { + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + super.onPageScrolled(position, positionOffset, positionOffsetPixels) + magicIndicator.onPageScrolled(position, positionOffset, positionOffsetPixels) + } + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + magicIndicator.onPageSelected(position) + onPageSelected?.invoke(position) + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + magicIndicator.onPageScrollStateChanged(state) + } + }) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt new file mode 100644 index 0000000..9fba54f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/GridSpaceItemDecoration.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +/** + * Created by HJW on 2020/11/19 + * @params 横条目数量,行间距,列间距 + */ +class GridSpaceItemDecoration( + private val spanCount: Int, + private val rowSpacing: Int = 0,//行间距 + private val columnSpacing: Int = 0, //列间距 + private val hasHead: Boolean = false +) : + RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) // 获取view 在adapter中的位置。 + + if (hasHead) { + if (position != 0) { + var column = position % spanCount - 1 // view 所在的列 + if (column == -1) column = spanCount - 1 + outRect.left = column * columnSpacing / spanCount // column * (列间距 * (1f / 列数)) + + outRect.right = columnSpacing - (column + 1) * columnSpacing / spanCount // 列间距 - (column + 1) * (列间距 * (1f /列数)) + + } + outRect.bottom = rowSpacing // item top + } else { + val column = position % spanCount // view 所在的列 + + outRect.left = column * columnSpacing / spanCount // column * (列间距 * (1f / 列数)) + outRect.right = columnSpacing - (column + 1) * columnSpacing / spanCount // 列间距 - (column + 1) * (列间距 * (1f /列数)) + + outRect.bottom = rowSpacing // item top + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt new file mode 100644 index 0000000..5672a85 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalGridSpaceItemDecoration.kt @@ -0,0 +1,42 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +/** + * Created by HJW on 2020/11/19 + * @params 横条目数量,行间距,列间距 + */ +class HorizontalGridSpaceItemDecoration( + private val spanCount: Int, + private val space: Int, //左右间距 + private val startSpace: Int = 0, + private val endSpace: Int = 0, + private val headerCount: Int = 0 +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) // 获取view 在adapter中的位置。 + + if (position < headerCount) { + return + } + + val totalCount = parent.adapter!!.itemCount + var totalRow = totalCount / spanCount // 总行数 + val d = totalCount % spanCount + if (d > 0) { + totalRow++ + } + var row = (position + 1) / spanCount // view 所在的行 + val p = (position + 1) % spanCount + if (p > 0) { + row++ + } + + outRect.left = if (position < spanCount) startSpace else space / 2 + outRect.right = if (row == totalRow) endSpace else space / 2 + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt new file mode 100644 index 0000000..bdb1d17 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/HorizontalItemDecoration.kt @@ -0,0 +1,56 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +//定义2个Item之间的距离 +class HorizontalItemDecoration(private val space: Int, private val startSpace: Int = 0, private val endSpace: Int = 0) : ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + val totalCount = parent.adapter!!.itemCount + if ((parent.layoutManager as LinearLayoutManager).reverseLayout) { + when (position) { + 0 -> { //第一个 + outRect.right = startSpace + outRect.left = space / 2 + } + + totalCount - 1 -> { //最后一个 + outRect.right = space / 2 + outRect.left = endSpace + } + + else -> { //中间其它的 + outRect.left = space / 2 + outRect.right = space / 2 + } + } + if (totalCount == 1) { + outRect.left = endSpace + } + } else { + when (position) { + 0 -> { //第一个 + outRect.left = startSpace + outRect.right = space / 2 + } + + totalCount - 1 -> { //最后一个 + outRect.left = space / 2 + outRect.right = endSpace + } + + else -> { //中间其它的 + outRect.left = space / 2 + outRect.right = space / 2 + } + } + if (totalCount == 1) { + outRect.right = endSpace + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt new file mode 100644 index 0000000..1196b9c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/SpaceItemDecoration.kt @@ -0,0 +1,36 @@ +package com.remax.visualnovel.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class SpaceItemDecoration constructor( + private var top: Int = 0, + private var left: Int = 0, + private var bottom: Int = 0, + private var right: Int = 0, + private var headerCount: Int = 0 +) : RecyclerView.ItemDecoration() { + + constructor(horizontal: Int, vertical: Int, headerCount: Int = 0) : this() { + top = vertical + bottom = vertical + + left = horizontal + right = horizontal + + this.headerCount = headerCount + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + super.getItemOffsets(outRect, view, parent, state) + val position = parent.getChildAdapterPosition(view) + if (position < headerCount) { + return + } + if (top > 0) outRect.top = top + if (left > 0) outRect.left = left + if (bottom > 0) outRect.bottom = bottom + if (right > 0) outRect.right = right + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt new file mode 100644 index 0000000..8cfce0f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/itemdecoration/VerticalItemDecoration.kt @@ -0,0 +1,38 @@ +package com.crushlevel.android.widget.itemdecoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +//定义2个Item之间的距离 +class VerticalItemDecoration( + private val space: Int, + private val topSpace: Int = 0, + private val bottomSpace: Int = 0, + private val headerCount: Int = 0 +) : ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + if (position < headerCount) { + return + } + val totalCount = parent.adapter!!.itemCount + when (position) { + 0 -> { //第一个 + outRect.top = topSpace + outRect.bottom = space / 2 + } + + totalCount - 1 -> { //最后一个 + outRect.top = space / 2 + outRect.bottom = bottomSpace + } + + else -> { //中间其它的 + outRect.top = space / 2 + outRect.bottom = space / 2 + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java new file mode 100644 index 0000000..22577a1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/Corner.java @@ -0,0 +1,18 @@ +package com.remax.visualnovel.widget.roundedimageview; + +import androidx.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.SOURCE) +@IntDef({ + Corner.TOP_LEFT, Corner.TOP_RIGHT, + Corner.BOTTOM_LEFT, Corner.BOTTOM_RIGHT +}) +public @interface Corner { + int TOP_LEFT = 0; + int TOP_RIGHT = 1; + int BOTTOM_RIGHT = 2; + int BOTTOM_LEFT = 3; +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java new file mode 100644 index 0000000..1e515bf --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedDrawable.java @@ -0,0 +1,618 @@ +package com.remax.visualnovel.widget.roundedimageview; + +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.widget.ImageView.ScaleType; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import java.util.HashSet; +import java.util.Set; + +@SuppressWarnings("UnusedDeclaration") +public class RoundedDrawable extends Drawable { + + public static final String TAG = "RoundedDrawable"; + public static final int DEFAULT_BORDER_COLOR = Color.BLACK; + + private final RectF mBounds = new RectF(); + private final RectF mDrawableRect = new RectF(); + private final RectF mBitmapRect = new RectF(); + private final Bitmap mBitmap; + private final Paint mBitmapPaint; + private final int mBitmapWidth; + private final int mBitmapHeight; + private final RectF mBorderRect = new RectF(); + private final Paint mBorderPaint; + private final Matrix mShaderMatrix = new Matrix(); + private final RectF mSquareCornersRect = new RectF(); + + private Shader.TileMode mTileModeX = Shader.TileMode.CLAMP; + private Shader.TileMode mTileModeY = Shader.TileMode.CLAMP; + private boolean mRebuildShader = true; + + private float mCornerRadius = 0f; + // [ topLeft, topRight, bottomLeft, bottomRight ] + private final boolean[] mCornersRounded = new boolean[] { true, true, true, true }; + + private boolean mOval = false; + private float mBorderWidth = 0; + private ColorStateList mBorderColor = ColorStateList.valueOf(DEFAULT_BORDER_COLOR); + private ScaleType mScaleType = ScaleType.FIT_CENTER; + + public RoundedDrawable(Bitmap bitmap) { + mBitmap = bitmap; + + mBitmapWidth = bitmap.getWidth(); + mBitmapHeight = bitmap.getHeight(); + mBitmapRect.set(0, 0, mBitmapWidth, mBitmapHeight); + + mBitmapPaint = new Paint(); + mBitmapPaint.setStyle(Paint.Style.FILL); + mBitmapPaint.setAntiAlias(true); + + mBorderPaint = new Paint(); + mBorderPaint.setStyle(Paint.Style.STROKE); + mBorderPaint.setAntiAlias(true); + mBorderPaint.setColor(mBorderColor.getColorForState(getState(), DEFAULT_BORDER_COLOR)); + mBorderPaint.setStrokeWidth(mBorderWidth); + } + + public static RoundedDrawable fromBitmap(Bitmap bitmap) { + if (bitmap != null) { + return new RoundedDrawable(bitmap); + } else { + return null; + } + } + + public static Drawable fromDrawable(Drawable drawable) { + if (drawable != null) { + if (drawable instanceof RoundedDrawable) { + // just return if it's already a RoundedDrawable + return drawable; + } else if (drawable instanceof LayerDrawable) { + ConstantState cs = drawable.mutate().getConstantState(); + LayerDrawable ld = (LayerDrawable) (cs != null ? cs.newDrawable() : drawable); + + int num = ld.getNumberOfLayers(); + + // loop through layers to and change to RoundedDrawables if possible + for (int i = 0; i < num; i++) { + Drawable d = ld.getDrawable(i); + ld.setDrawableByLayerId(ld.getId(i), fromDrawable(d)); + } + return ld; + } + + // try to get a bitmap from the drawable and + Bitmap bm = drawableToBitmap(drawable); + if (bm != null) { + return new RoundedDrawable(bm); + } + } + return drawable; + } + + public static Bitmap drawableToBitmap(Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable) drawable).getBitmap(); + } + + Bitmap bitmap; + int width = Math.max(drawable.getIntrinsicWidth(), 2); + int height = Math.max(drawable.getIntrinsicHeight(), 2); + try { + bitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + } catch (Throwable e) { + e.printStackTrace(); + bitmap = null; + } + + return bitmap; + } + + public Bitmap getSourceBitmap() { + return mBitmap; + } + + @Override + public boolean isStateful() { + return mBorderColor.isStateful(); + } + + @Override + protected boolean onStateChange(int[] state) { + int newColor = mBorderColor.getColorForState(state, 0); + if (mBorderPaint.getColor() != newColor) { + mBorderPaint.setColor(newColor); + return true; + } else { + return super.onStateChange(state); + } + } + + private void updateShaderMatrix() { + float scale; + float dx; + float dy; + + switch (mScaleType) { + case CENTER: + mBorderRect.set(mBounds); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + + mShaderMatrix.reset(); + mShaderMatrix.setTranslate((int) ((mBorderRect.width() - mBitmapWidth) * 0.5f + 0.5f), + (int) ((mBorderRect.height() - mBitmapHeight) * 0.5f + 0.5f)); + break; + + case CENTER_CROP: + mBorderRect.set(mBounds); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + + mShaderMatrix.reset(); + + dx = 0; + dy = 0; + + if (mBitmapWidth * mBorderRect.height() > mBorderRect.width() * mBitmapHeight) { + scale = mBorderRect.height() / (float) mBitmapHeight; + dx = (mBorderRect.width() - mBitmapWidth * scale) * 0.5f; + } else { + scale = mBorderRect.width() / (float) mBitmapWidth; + dy = (mBorderRect.height() - mBitmapHeight * scale) * 0.5f; + } + + mShaderMatrix.setScale(scale, scale); + mShaderMatrix.postTranslate((int) (dx + 0.5f) + mBorderWidth / 2, + (int) (dy + 0.5f) + mBorderWidth / 2); + break; + + case CENTER_INSIDE: + mShaderMatrix.reset(); + + if (mBitmapWidth <= mBounds.width() && mBitmapHeight <= mBounds.height()) { + scale = 1.0f; + } else { + scale = Math.min(mBounds.width() / (float) mBitmapWidth, + mBounds.height() / (float) mBitmapHeight); + } + + dx = (int) ((mBounds.width() - mBitmapWidth * scale) * 0.5f + 0.5f); + dy = (int) ((mBounds.height() - mBitmapHeight * scale) * 0.5f + 0.5f); + + mShaderMatrix.setScale(scale, scale); + mShaderMatrix.postTranslate(dx, dy); + + mBorderRect.set(mBitmapRect); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + default: + case FIT_CENTER: + mBorderRect.set(mBitmapRect); + mShaderMatrix.setRectToRect(mBitmapRect, mBounds, Matrix.ScaleToFit.CENTER); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + case FIT_END: + mBorderRect.set(mBitmapRect); + mShaderMatrix.setRectToRect(mBitmapRect, mBounds, Matrix.ScaleToFit.END); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + case FIT_START: + mBorderRect.set(mBitmapRect); + mShaderMatrix.setRectToRect(mBitmapRect, mBounds, Matrix.ScaleToFit.START); + mShaderMatrix.mapRect(mBorderRect); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + + case FIT_XY: + mBorderRect.set(mBounds); + mBorderRect.inset(mBorderWidth / 2, mBorderWidth / 2); + mShaderMatrix.reset(); + mShaderMatrix.setRectToRect(mBitmapRect, mBorderRect, Matrix.ScaleToFit.FILL); + break; + } + + mDrawableRect.set(mBorderRect); + mRebuildShader = true; + } + + @Override + protected void onBoundsChange(@NonNull Rect bounds) { + super.onBoundsChange(bounds); + + mBounds.set(bounds); + + updateShaderMatrix(); + } + + @Override + public void draw(@NonNull Canvas canvas) { + if (mRebuildShader) { + BitmapShader bitmapShader = new BitmapShader(mBitmap, mTileModeX, mTileModeY); + if (mTileModeX == Shader.TileMode.CLAMP && mTileModeY == Shader.TileMode.CLAMP) { + bitmapShader.setLocalMatrix(mShaderMatrix); + } + mBitmapPaint.setShader(bitmapShader); + mRebuildShader = false; + } + + if (mOval) { + if (mBorderWidth > 0) { + canvas.drawOval(mDrawableRect, mBitmapPaint); + canvas.drawOval(mBorderRect, mBorderPaint); + } else { + canvas.drawOval(mDrawableRect, mBitmapPaint); + } + } else { + if (any(mCornersRounded)) { + float radius = mCornerRadius; + if (mBorderWidth > 0) { + canvas.drawRoundRect(mDrawableRect, radius, radius, mBitmapPaint); + canvas.drawRoundRect(mBorderRect, radius, radius, mBorderPaint); + redrawBitmapForSquareCorners(canvas); + redrawBorderForSquareCorners(canvas); + } else { + canvas.drawRoundRect(mDrawableRect, radius, radius, mBitmapPaint); + redrawBitmapForSquareCorners(canvas); + } + } else { + canvas.drawRect(mDrawableRect, mBitmapPaint); + if (mBorderWidth > 0) { + canvas.drawRect(mBorderRect, mBorderPaint); + } + } + } + } + + private void redrawBitmapForSquareCorners(Canvas canvas) { + if (all(mCornersRounded)) { + // no square corners + return; + } + + if (mCornerRadius == 0) { + return; // no round corners + } + + float left = mDrawableRect.left; + float top = mDrawableRect.top; + float right = left + mDrawableRect.width(); + float bottom = top + mDrawableRect.height(); + float radius = mCornerRadius; + + if (!mCornersRounded[Corner.TOP_LEFT]) { + mSquareCornersRect.set(left, top, left + radius, top + radius); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + + if (!mCornersRounded[Corner.TOP_RIGHT]) { + mSquareCornersRect.set(right - radius, top, right, radius); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_RIGHT]) { + mSquareCornersRect.set(right - radius, bottom - radius, right, bottom); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_LEFT]) { + mSquareCornersRect.set(left, bottom - radius, left + radius, bottom); + canvas.drawRect(mSquareCornersRect, mBitmapPaint); + } + } + + private void redrawBorderForSquareCorners(Canvas canvas) { + if (all(mCornersRounded)) { + // no square corners + return; + } + + if (mCornerRadius == 0) { + return; // no round corners + } + + float left = mDrawableRect.left; + float top = mDrawableRect.top; + float right = left + mDrawableRect.width(); + float bottom = top + mDrawableRect.height(); + float radius = mCornerRadius; + float offset = mBorderWidth / 2; + + if (!mCornersRounded[Corner.TOP_LEFT]) { + canvas.drawLine(left - offset, top, left + radius, top, mBorderPaint); + canvas.drawLine(left, top - offset, left, top + radius, mBorderPaint); + } + + if (!mCornersRounded[Corner.TOP_RIGHT]) { + canvas.drawLine(right - radius - offset, top, right, top, mBorderPaint); + canvas.drawLine(right, top - offset, right, top + radius, mBorderPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_RIGHT]) { + canvas.drawLine(right - radius - offset, bottom, right + offset, bottom, mBorderPaint); + canvas.drawLine(right, bottom - radius, right, bottom, mBorderPaint); + } + + if (!mCornersRounded[Corner.BOTTOM_LEFT]) { + canvas.drawLine(left - offset, bottom, left + radius, bottom, mBorderPaint); + canvas.drawLine(left, bottom - radius, left, bottom, mBorderPaint); + } + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getAlpha() { + return mBitmapPaint.getAlpha(); + } + + @Override + public void setAlpha(int alpha) { + mBitmapPaint.setAlpha(alpha); + invalidateSelf(); + } + + @Override + public ColorFilter getColorFilter() { + return mBitmapPaint.getColorFilter(); + } + + @Override + public void setColorFilter(ColorFilter cf) { + mBitmapPaint.setColorFilter(cf); + invalidateSelf(); + } + + @Override + public void setDither(boolean dither) { + mBitmapPaint.setDither(dither); + invalidateSelf(); + } + + @Override + public void setFilterBitmap(boolean filter) { + mBitmapPaint.setFilterBitmap(filter); + invalidateSelf(); + } + + @Override + public int getIntrinsicWidth() { + return mBitmapWidth; + } + + @Override + public int getIntrinsicHeight() { + return mBitmapHeight; + } + + /** + * @return the corner radius. + */ + public float getCornerRadius() { + return mCornerRadius; + } + + /** + * @param corner the specific corner to get radius of. + * @return the corner radius of the specified corner. + */ + public float getCornerRadius(@Corner int corner) { + return mCornersRounded[corner] ? mCornerRadius : 0f; + } + + /** + * Sets all corners to the specified radius. + * + * @param radius the radius. + * @return the {@link RoundedDrawable} for chaining. + */ + public RoundedDrawable setCornerRadius(float radius) { + setCornerRadius(radius, radius, radius, radius); + return this; + } + + /** + * Sets the corner radius of one specific corner. + * + * @param corner the corner. + * @param radius the radius. + * @return the {@link RoundedDrawable} for chaining. + */ + public RoundedDrawable setCornerRadius(@Corner int corner, float radius) { + if (radius != 0 && mCornerRadius != 0 && mCornerRadius != radius) { + throw new IllegalArgumentException("Multiple nonzero corner radii not yet supported."); + } + + if (radius == 0) { + if (only(corner, mCornersRounded)) { + mCornerRadius = 0; + } + mCornersRounded[corner] = false; + } else { + if (mCornerRadius == 0) { + mCornerRadius = radius; + } + mCornersRounded[corner] = true; + } + + return this; + } + + /** + * Sets the corner radii of all the corners. + * + * @param topLeft top left corner radius. + * @param topRight top right corner radius + * @param bottomRight bototm right corner radius. + * @param bottomLeft bottom left corner radius. + * @return the {@link RoundedDrawable} for chaining. + */ + public RoundedDrawable setCornerRadius(float topLeft, float topRight, float bottomRight, + float bottomLeft) { + Set radiusSet = new HashSet<>(4); + radiusSet.add(topLeft); + radiusSet.add(topRight); + radiusSet.add(bottomRight); + radiusSet.add(bottomLeft); + + radiusSet.remove(0f); + + if (radiusSet.size() > 1) { + throw new IllegalArgumentException("Multiple nonzero corner radii not yet supported."); + } + + if (!radiusSet.isEmpty()) { + float radius = radiusSet.iterator().next(); + if (Float.isInfinite(radius) || Float.isNaN(radius) || radius < 0) { + throw new IllegalArgumentException("Invalid radius value: " + radius); + } + mCornerRadius = radius; + } else { + mCornerRadius = 0f; + } + + mCornersRounded[Corner.TOP_LEFT] = topLeft > 0; + mCornersRounded[Corner.TOP_RIGHT] = topRight > 0; + mCornersRounded[Corner.BOTTOM_RIGHT] = bottomRight > 0; + mCornersRounded[Corner.BOTTOM_LEFT] = bottomLeft > 0; + return this; + } + + public float getBorderWidth() { + return mBorderWidth; + } + + public RoundedDrawable setBorderWidth(float width) { + mBorderWidth = width; + mBorderPaint.setStrokeWidth(mBorderWidth); + return this; + } + + public int getBorderColor() { + return mBorderColor.getDefaultColor(); + } + + public RoundedDrawable setBorderColor(@ColorInt int color) { + return setBorderColor(ColorStateList.valueOf(color)); + } + + public ColorStateList getBorderColors() { + return mBorderColor; + } + + public RoundedDrawable setBorderColor(ColorStateList colors) { + mBorderColor = colors != null ? colors : ColorStateList.valueOf(0); + mBorderPaint.setColor(mBorderColor.getColorForState(getState(), DEFAULT_BORDER_COLOR)); + return this; + } + + public boolean isOval() { + return mOval; + } + + public RoundedDrawable setOval(boolean oval) { + mOval = oval; + return this; + } + + public ScaleType getScaleType() { + return mScaleType; + } + + public RoundedDrawable setScaleType(ScaleType scaleType) { + if (scaleType == null) { + scaleType = ScaleType.FIT_CENTER; + } + if (mScaleType != scaleType) { + mScaleType = scaleType; + updateShaderMatrix(); + } + return this; + } + + public Shader.TileMode getTileModeX() { + return mTileModeX; + } + + public RoundedDrawable setTileModeX(Shader.TileMode tileModeX) { + if (mTileModeX != tileModeX) { + mTileModeX = tileModeX; + mRebuildShader = true; + invalidateSelf(); + } + return this; + } + + public Shader.TileMode getTileModeY() { + return mTileModeY; + } + + public RoundedDrawable setTileModeY(Shader.TileMode tileModeY) { + if (mTileModeY != tileModeY) { + mTileModeY = tileModeY; + mRebuildShader = true; + invalidateSelf(); + } + return this; + } + + private static boolean only(int index, boolean[] booleans) { + for (int i = 0, len = booleans.length; i < len; i++) { + if (booleans[i] != (i == index)) { + return false; + } + } + return true; + } + + private static boolean any(boolean[] booleans) { + for (boolean b : booleans) { + if (b) { return true; } + } + return false; + } + + private static boolean all(boolean[] booleans) { + for (boolean b : booleans) { + if (b) { return false; } + } + return true; + } + + public Bitmap toBitmap() { + return drawableToBitmap(this); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java new file mode 100644 index 0000000..3f56a9a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/roundedimageview/RoundedImageView.java @@ -0,0 +1,587 @@ +package com.remax.visualnovel.widget.roundedimageview; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.ColorFilter; +import android.graphics.Shader; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.util.AttributeSet; + +import androidx.annotation.ColorInt; +import androidx.annotation.DimenRes; +import androidx.annotation.DrawableRes; +import androidx.appcompat.widget.AppCompatImageView; + +import com.remax.visualnovel.R; + +@SuppressWarnings("UnusedDeclaration") +public class RoundedImageView extends AppCompatImageView { + + // Constants for tile mode attributes + private static final int TILE_MODE_UNDEFINED = -2; + private static final int TILE_MODE_CLAMP = 0; + private static final int TILE_MODE_REPEAT = 1; + private static final int TILE_MODE_MIRROR = 2; + + public static final String TAG = "RoundedImageView"; + public static final float DEFAULT_RADIUS = 0f; + public static final float DEFAULT_BORDER_WIDTH = 0f; + public static final Shader.TileMode DEFAULT_TILE_MODE = Shader.TileMode.CLAMP; + private static final ScaleType[] SCALE_TYPES = { + ScaleType.MATRIX, + ScaleType.FIT_XY, + ScaleType.FIT_START, + ScaleType.FIT_CENTER, + ScaleType.FIT_END, + ScaleType.CENTER, + ScaleType.CENTER_CROP, + ScaleType.CENTER_INSIDE + }; + + private final float[] mCornerRadii = + new float[] { DEFAULT_RADIUS, DEFAULT_RADIUS, DEFAULT_RADIUS, DEFAULT_RADIUS }; + + private Drawable mBackgroundDrawable; + private ColorStateList mBorderColor = + ColorStateList.valueOf(RoundedDrawable.DEFAULT_BORDER_COLOR); + private float mBorderWidth = DEFAULT_BORDER_WIDTH; + private ColorFilter mColorFilter = null; + private boolean mColorMod = false; + private Drawable mDrawable; + private boolean mHasColorFilter = false; + private boolean mIsOval = false; + private boolean mMutateBackground = false; + private int mResource; + private int mBackgroundResource; + private ScaleType mScaleType; + private Shader.TileMode mTileModeX = DEFAULT_TILE_MODE; + private Shader.TileMode mTileModeY = DEFAULT_TILE_MODE; + + public RoundedImageView(Context context) { + super(context); + } + + public RoundedImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RoundedImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RoundedImageView, defStyle, 0); + + int index = a.getInt(R.styleable.RoundedImageView_android_scaleType, -1); + if (index >= 0) { + setScaleType(SCALE_TYPES[index]); + } else { + // default scaletype to FIT_CENTER + setScaleType(ScaleType.FIT_CENTER); + } + + float cornerRadiusOverride = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius, -1); + + mCornerRadii[Corner.TOP_LEFT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_top_left, -1); + mCornerRadii[Corner.TOP_RIGHT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_top_right, -1); + mCornerRadii[Corner.BOTTOM_RIGHT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_bottom_right, -1); + mCornerRadii[Corner.BOTTOM_LEFT] = + a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_corner_radius_bottom_left, -1); + + boolean any = false; + for (int i = 0, len = mCornerRadii.length; i < len; i++) { + if (mCornerRadii[i] < 0) { + mCornerRadii[i] = 0f; + } else { + any = true; + } + } + + if (!any) { + if (cornerRadiusOverride < 0) { + cornerRadiusOverride = DEFAULT_RADIUS; + } + for (int i = 0, len = mCornerRadii.length; i < len; i++) { + mCornerRadii[i] = cornerRadiusOverride; + } + } + + mBorderWidth = a.getDimensionPixelSize(R.styleable.RoundedImageView_riv_border_width, -1); + if (mBorderWidth < 0) { + mBorderWidth = DEFAULT_BORDER_WIDTH; + } + + mBorderColor = a.getColorStateList(R.styleable.RoundedImageView_riv_border_color); + if (mBorderColor == null) { + mBorderColor = ColorStateList.valueOf(RoundedDrawable.DEFAULT_BORDER_COLOR); + } + + mMutateBackground = a.getBoolean(R.styleable.RoundedImageView_riv_mutate_background, false); + mIsOval = a.getBoolean(R.styleable.RoundedImageView_riv_oval, false); + + final int tileMode = a.getInt(R.styleable.RoundedImageView_riv_tile_mode, TILE_MODE_UNDEFINED); + if (tileMode != TILE_MODE_UNDEFINED) { + setTileModeX(parseTileMode(tileMode)); + setTileModeY(parseTileMode(tileMode)); + } + + final int tileModeX = + a.getInt(R.styleable.RoundedImageView_riv_tile_mode_x, TILE_MODE_UNDEFINED); + if (tileModeX != TILE_MODE_UNDEFINED) { + setTileModeX(parseTileMode(tileModeX)); + } + + final int tileModeY = + a.getInt(R.styleable.RoundedImageView_riv_tile_mode_y, TILE_MODE_UNDEFINED); + if (tileModeY != TILE_MODE_UNDEFINED) { + setTileModeY(parseTileMode(tileModeY)); + } + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(true); + + if (mMutateBackground) { + //noinspection deprecation + super.setBackgroundDrawable(mBackgroundDrawable); + } + + a.recycle(); + } + + private static Shader.TileMode parseTileMode(int tileMode) { + switch (tileMode) { + case TILE_MODE_CLAMP: + return Shader.TileMode.CLAMP; + case TILE_MODE_REPEAT: + return Shader.TileMode.REPEAT; + case TILE_MODE_MIRROR: + return Shader.TileMode.MIRROR; + default: + return null; + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + invalidate(); + } + + @Override + public ScaleType getScaleType() { + return mScaleType; + } + + @Override + public void setScaleType(ScaleType scaleType) { + assert scaleType != null; + + if (mScaleType != scaleType) { + mScaleType = scaleType; + + switch (scaleType) { + case CENTER: + case CENTER_CROP: + case CENTER_INSIDE: + case FIT_CENTER: + case FIT_START: + case FIT_END: + case FIT_XY: + super.setScaleType(ScaleType.FIT_XY); + break; + default: + super.setScaleType(scaleType); + break; + } + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + } + + @Override + public void setImageDrawable(Drawable drawable) { + mResource = 0; + mDrawable = RoundedDrawable.fromDrawable(drawable); + updateDrawableAttrs(); + super.setImageDrawable(mDrawable); + } + + @Override + public void setImageBitmap(Bitmap bm) { + mResource = 0; + mDrawable = RoundedDrawable.fromBitmap(bm); + updateDrawableAttrs(); + super.setImageDrawable(mDrawable); + } + + @Override + public void setImageResource(@DrawableRes int resId) { + if (mResource != resId) { + mResource = resId; + mDrawable = resolveResource(); + updateDrawableAttrs(); + super.setImageDrawable(mDrawable); + } + } + + @Override public void setImageURI(Uri uri) { + super.setImageURI(uri); + setImageDrawable(getDrawable()); + } + + private Drawable resolveResource() { + Resources rsrc = getResources(); + if (rsrc == null) { return null; } + + Drawable d = null; + + if (mResource != 0) { + try { + d = rsrc.getDrawable(mResource); + } catch (Exception e) { + // Don't try again. + mResource = 0; + } + } + return RoundedDrawable.fromDrawable(d); + } + + @Override + public void setBackground(Drawable background) { + setBackgroundDrawable(background); + } + + @Override + public void setBackgroundResource(@DrawableRes int resId) { + if (mBackgroundResource != resId) { + mBackgroundResource = resId; + mBackgroundDrawable = resolveBackgroundResource(); + setBackgroundDrawable(mBackgroundDrawable); + } + } + + @Override + public void setBackgroundColor(int color) { + mBackgroundDrawable = new ColorDrawable(color); + setBackgroundDrawable(mBackgroundDrawable); + } + + private Drawable resolveBackgroundResource() { + Resources rsrc = getResources(); + if (rsrc == null) { return null; } + + Drawable d = null; + + if (mBackgroundResource != 0) { + try { + d = rsrc.getDrawable(mBackgroundResource); + } catch (Exception e) { + // Don't try again. + mBackgroundResource = 0; + } + } + return RoundedDrawable.fromDrawable(d); + } + + private void updateDrawableAttrs() { + updateAttrs(mDrawable, mScaleType); + } + + private void updateBackgroundDrawableAttrs(boolean convert) { + if (mMutateBackground) { + if (convert) { + mBackgroundDrawable = RoundedDrawable.fromDrawable(mBackgroundDrawable); + } + updateAttrs(mBackgroundDrawable, ScaleType.FIT_XY); + } + } + + @Override public void setColorFilter(ColorFilter cf) { + if (mColorFilter != cf) { + mColorFilter = cf; + mHasColorFilter = true; + mColorMod = true; + applyColorMod(); + invalidate(); + } + } + + private void applyColorMod() { + // Only mutate and apply when modifications have occurred. This should + // not reset the mColorMod flag, since these filters need to be + // re-applied if the Drawable is changed. + if (mDrawable != null && mColorMod) { + mDrawable = mDrawable.mutate(); + if (mHasColorFilter) { + mDrawable.setColorFilter(mColorFilter); + } + //mDrawable.setXfermode(mXfermode); + //mDrawable.setAlpha(mAlpha * mViewAlphaScale >> 8); + } + } + + private void updateAttrs(Drawable drawable, ScaleType scaleType) { + if (drawable == null) { return; } + + if (drawable instanceof RoundedDrawable) { + ((RoundedDrawable) drawable) + .setScaleType(scaleType) + .setBorderWidth(mBorderWidth) + .setBorderColor(mBorderColor) + .setOval(mIsOval) + .setTileModeX(mTileModeX) + .setTileModeY(mTileModeY); + + if (mCornerRadii != null) { + ((RoundedDrawable) drawable).setCornerRadius( + mCornerRadii[Corner.TOP_LEFT], + mCornerRadii[Corner.TOP_RIGHT], + mCornerRadii[Corner.BOTTOM_RIGHT], + mCornerRadii[Corner.BOTTOM_LEFT]); + } + + applyColorMod(); + } else if (drawable instanceof LayerDrawable) { + // loop through layers to and set drawable attrs + LayerDrawable ld = ((LayerDrawable) drawable); + for (int i = 0, layers = ld.getNumberOfLayers(); i < layers; i++) { + updateAttrs(ld.getDrawable(i), scaleType); + } + } + } + + @Override + @Deprecated + public void setBackgroundDrawable(Drawable background) { + mBackgroundDrawable = background; + updateBackgroundDrawableAttrs(true); + //noinspection deprecation + super.setBackgroundDrawable(mBackgroundDrawable); + } + + /** + * @return the largest corner radius. + */ + public float getCornerRadius() { + return getMaxCornerRadius(); + } + + /** + * @return the largest corner radius. + */ + public float getMaxCornerRadius() { + float maxRadius = 0; + for (float r : mCornerRadii) { + maxRadius = Math.max(r, maxRadius); + } + return maxRadius; + } + + /** + * Get the corner radius of a specified corner. + * + * @param corner the corner. + * @return the radius. + */ + public float getCornerRadius(@Corner int corner) { + return mCornerRadii[corner]; + } + + /** + * Set all the corner radii from a dimension resource id. + * + * @param resId dimension resource id of radii. + */ + public void setCornerRadiusDimen(@DimenRes int resId) { + float radius = getResources().getDimension(resId); + setCornerRadius(radius, radius, radius, radius); + } + + /** + * Set the corner radius of a specific corner from a dimension resource id. + * + * @param corner the corner to set. + * @param resId the dimension resource id of the corner radius. + */ + public void setCornerRadiusDimen(@Corner int corner, @DimenRes int resId) { + setCornerRadius(corner, getResources().getDimensionPixelSize(resId)); + } + + /** + * Set the corner radii of all corners in px. + * + * @param radius the radius to set. + */ + public void setCornerRadius(float radius) { + setCornerRadius(radius, radius, radius, radius); + } + + /** + * Set the corner radius of a specific corner in px. + * + * @param corner the corner to set. + * @param radius the corner radius to set in px. + */ + public void setCornerRadius(@Corner int corner, float radius) { + if (mCornerRadii[corner] == radius) { + return; + } + mCornerRadii[corner] = radius; + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + /** + * Set the corner radii of each corner individually. Currently only one unique nonzero value is + * supported. + * + * @param topLeft radius of the top left corner in px. + * @param topRight radius of the top right corner in px. + * @param bottomRight radius of the bottom right corner in px. + * @param bottomLeft radius of the bottom left corner in px. + */ + public void setCornerRadius(float topLeft, float topRight, float bottomLeft, float bottomRight) { + if (mCornerRadii[Corner.TOP_LEFT] == topLeft + && mCornerRadii[Corner.TOP_RIGHT] == topRight + && mCornerRadii[Corner.BOTTOM_RIGHT] == bottomRight + && mCornerRadii[Corner.BOTTOM_LEFT] == bottomLeft) { + return; + } + + mCornerRadii[Corner.TOP_LEFT] = topLeft; + mCornerRadii[Corner.TOP_RIGHT] = topRight; + mCornerRadii[Corner.BOTTOM_LEFT] = bottomLeft; + mCornerRadii[Corner.BOTTOM_RIGHT] = bottomRight; + + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + public float getBorderWidth() { + return mBorderWidth; + } + + public void setBorderWidth(@DimenRes int resId) { + setBorderWidth(getResources().getDimension(resId)); + } + + public void setBorderWidth(float width) { + if (mBorderWidth == width) { return; } + + mBorderWidth = width; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + @ColorInt + public int getBorderColor() { + return mBorderColor.getDefaultColor(); + } + + public void setBorderColor(@ColorInt int color) { + setBorderColor(ColorStateList.valueOf(color)); + } + + public ColorStateList getBorderColors() { + return mBorderColor; + } + + public void setBorderColor(ColorStateList colors) { + if (mBorderColor.equals(colors)) { return; } + + mBorderColor = + (colors != null) ? colors : ColorStateList.valueOf(RoundedDrawable.DEFAULT_BORDER_COLOR); + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + if (mBorderWidth > 0) { + invalidate(); + } + } + + /** + * Return true if this view should be oval and always set corner radii to half the height or + * width. + * + * @return if this {@link RoundedImageView} is set to oval. + */ + public boolean isOval() { + return mIsOval; + } + + /** + * Set if the drawable should ignore the corner radii set and always round the source to + * exactly half the height or width. + * + * @param oval if this {@link RoundedImageView} should be oval. + */ + public void setOval(boolean oval) { + mIsOval = oval; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + public Shader.TileMode getTileModeX() { + return mTileModeX; + } + + public void setTileModeX(Shader.TileMode tileModeX) { + if (this.mTileModeX == tileModeX) { return; } + + this.mTileModeX = tileModeX; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + public Shader.TileMode getTileModeY() { + return mTileModeY; + } + + public void setTileModeY(Shader.TileMode tileModeY) { + if (this.mTileModeY == tileModeY) { return; } + + this.mTileModeY = tileModeY; + updateDrawableAttrs(); + updateBackgroundDrawableAttrs(false); + invalidate(); + } + + /** + * If {@code true}, we will also round the background drawable according to the settings on this + * ImageView. + * + * @return whether the background is mutated. + */ + public boolean mutatesBackground() { + return mMutateBackground; + } + + /** + * Set whether the {@link RoundedImageView} should round the background drawable according to + * the settings in addition to the source drawable. + * + * @param mutate true if this view should mutate the background drawable. + */ + public void mutateBackground(boolean mutate) { + if (mMutateBackground == mutate) { return; } + + mMutateBackground = mutate; + updateBackgroundDrawableAttrs(true); + invalidate(); + } +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt new file mode 100644 index 0000000..cb7c55f --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/CheckBoxButton.kt @@ -0,0 +1,64 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.expend.dsl.expandDp +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class CheckBoxButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + + private val viewSize = 20.dp + + var isChecked = false + + init { + context.withStyledAttributes(attrs, R.styleable.CheckBoxButton) { + isChecked = getBoolean(R.styleable.CheckBoxButton_radioCheck, false) + } + expandDp(14, 14) + viewChecked(isChecked) + } + + override fun setVisibility(visibility: Int) { + super.setVisibility(visibility) + if (isVisible) { + expandDp(14, 14) + } + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + if (enabled) { + viewChecked(isChecked) + } else { + setBackgroundResource(R.mipmap.icon_multi_checked_disabled) + } + } + + fun viewChecked(isChecked: Boolean) { + this.isChecked = isChecked + if (isChecked) { + setBackgroundResource(R.mipmap.icon_multi_checked) + } else { + setBackgroundResource(R.mipmap.checkbox_normal) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + setMeasuredDimension(widthMeasureSpec, heightMeasureSpec) + } else { + setMeasuredDimension(viewSize, viewSize) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt new file mode 100644 index 0000000..d171810 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontDrawable.kt @@ -0,0 +1,191 @@ +package com.remax.visualnovel.widget.ui + + +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.text.TextPaint +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp + + +/** + * Embed an icon into a Drawable that can be used as TextView icons, or ActionBar icons. + *

+ * new IconDrawable(context, IconValue.icon_star)
+ * .colorRes(R.color.white)
+ * .actionBarSize();
+
* + * If you don't set the size of the drawable, it will use the size + * that is given to him. Note that in an ActionBar, if you don't + * set the size explicitly it uses 0, so please use actionBarSize(). + */ +class IconFontDrawable : Drawable { + private var context: Context? = null + private var icon: String? = null + private var paint: TextPaint? = null + private var size = -1 + private var alphaValue = 255 + + /** + * Create an IconDrawable. + * + * @param context Your activity or application context. + * @param icon The icon key you want this drawable to display. + */ + constructor(context: Context, icon: String) { + init(context, icon) + } + + constructor(context: Context, @StringRes iconKey: Int) { + init(context, context.getString(iconKey)) + } + + private fun init(context: Context, icon: String) { + this.context = context + this.icon = icon + paint = TextPaint().apply { + typeface = context.getIconFontType() + style = Paint.Style.FILL + textAlign = Paint.Align.CENTER + isUnderlineText = false + color = Color.BLACK + isAntiAlias = true + } + } + + /** + * Set the size of the drawable. + * + * @param dimenRes The dimension resource. + * @return The current IconDrawable for chaining. + */ + fun sizeRes(dimenRes: Int): IconFontDrawable { + return sizePx(context!!.resources.getDimensionPixelSize(dimenRes)) + } + + /** + * Set the size of the drawable. + * + * @param size The size in density-independent pixels (dp). + * @return The current IconDrawable for chaining. + */ + fun sizeDp(size: Int): IconFontDrawable { + return sizePx(size.dp) + } + + /** + * Set the size of the drawable. + * + * @param size The size in pixels (px). + * @return The current IconDrawable for chaining. + */ + fun sizePx(size: Int): IconFontDrawable { + this.size = size + setBounds(0, 0, size, size) + invalidateSelf() + return this + } + + /** + * Set the color of the drawable. + * + * @param color The color, usually from android.graphics.Color or 0xFF012345. + * @return The current IconDrawable for chaining. + */ + fun color(color: Int): IconFontDrawable { + paint?.color = color + invalidateSelf() + return this + } + + /** + * Set the color of the drawable. + * + * @param colorRes The color resource, from your R file. + * @return The current IconDrawable for chaining. + */ + fun colorRes(colorRes: Int): IconFontDrawable { + paint?.color = ContextCompat.getColor(context!!, colorRes) + invalidateSelf() + return this + } + + /** + * Set the alpha of this drawable. + * + * @param alpha The alpha, between 0 (transparent) and 255 (opaque). + * @return The current IconDrawable for chaining. + */ + fun alpha(alpha: Int): IconFontDrawable { + setAlpha(alpha) + invalidateSelf() + return this + } + + override fun getIntrinsicHeight(): Int { + return size + } + + override fun getIntrinsicWidth(): Int { + return size + } + + override fun draw(canvas: Canvas) { + val bounds = bounds + val height = bounds.height() + paint?.textSize = height.toFloat() + val textBounds = Rect() + val textValue = icon.toString() + paint?.getTextBounds(textValue, 0, 1, textBounds) + val textHeight = textBounds.height() + val textBottom = bounds.top + (height - textHeight) / 2f + textHeight - textBounds.bottom + canvas.drawText(textValue, bounds.exactCenterX(), textBottom, paint!!) + } + + override fun isStateful(): Boolean { + return true + } + + override fun setState(stateSet: IntArray): Boolean { + val oldValue = paint?.alpha + val newValue = if (isEnabled(stateSet)) alphaValue else alphaValue / 2 + paint?.alpha = newValue + return oldValue != newValue + } + + override fun setAlpha(alpha: Int) { + alphaValue = alpha + paint?.alpha = alpha + } + + override fun setColorFilter(cf: ColorFilter?) { + paint?.colorFilter = cf + } + + override fun clearColorFilter() { + paint?.colorFilter = null + } + + @Deprecated("Deprecated in Java", ReplaceWith("alpha")) + override fun getOpacity(): Int { + return alpha + } + + /** + * Sets paint style. + * + * @param style to be applied + */ + fun setStyle(style: Paint.Style?) { + paint?.style = style + } + + // Util + private fun isEnabled(stateSet: IntArray): Boolean { + for (state in stateSet) if (state == android.R.attr.state_enabled) return true + return false + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt new file mode 100644 index 0000000..1d45e37 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt @@ -0,0 +1,114 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import androidx.core.content.withStyledAttributes + +/** + * Created by wl on 2022/9/6 + * IconFont控件 可用于icon+文字、纯icon、纯文字 + */ +open class IconFontTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + private var iconFontStart: String? = null //加在文字前面的icon + private var iconFontEnd: String? = null //文字后面icon + private var iconFontTop: String? = null + private var iconFontBottom: String? = null + + private var iconFontDrawablePadding: Int = 0 //drawable间距 px + private var iconFontSize: Int = 0 //iconFont的大小 + private var iconFontColorToken: String? = null //iconFont颜色 + + init { + context.withStyledAttributes(attrs, R.styleable.IconFontTextView) { + iconFontStart = getString(R.styleable.IconFontTextView_iconFontStart) + iconFontEnd = getString(R.styleable.IconFontTextView_iconFontEnd) + iconFontTop = getString(R.styleable.IconFontTextView_iconFontTop) + iconFontBottom = getString(R.styleable.IconFontTextView_iconFontBottom) + iconFontDrawablePadding = getDimension(R.styleable.IconFontTextView_iconFontDrawablePadding, 0f).toInt() + iconFontSize = getDimensionPixelOffset(R.styleable.IconFontTextView_iconFontSize, 0) + iconFontColorToken = getString(R.styleable.IconFontTextView_iconFontColorToken) + } + + setIconFontDrawable(iconFontStart, iconFontTop, iconFontEnd, iconFontBottom, iconSize = iconFontSize, isDp = false) + + transformationMethod = null + } + + /** + * 设置iconFont + * @param startIconFont 左图标 + * @param topIconFont 上图标 + * @param endIconFont 右图标 + * @param bottomIconFont 下图标 + * @param iconColorToken icon的color token + * @param iconSize icon的textSize + * @param iconPadding icon与文字的间距 + */ + fun setIconFontDrawable( + startIconFont: String? = null, + topIconFont: String? = null, + endIconFont: String? = null, + bottomIconFont: String? = null, + iconColorToken: String? = null, + iconSize: Int = 0, + isDp: Boolean = true, + iconPadding: Int? = null + ) { + var startDrawable: IconFontDrawable? = null + var endDrawable: IconFontDrawable? = null + var topDrawable: IconFontDrawable? = null + var bottomDrawable: IconFontDrawable? = null + if (!startIconFont.isNullOrEmpty()) { + startDrawable = IconFontDrawable(context, startIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (!endIconFont.isNullOrEmpty()) { + endDrawable = + IconFontDrawable(context, endIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (!topIconFont.isNullOrEmpty()) { + topDrawable = IconFontDrawable(context, topIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (!bottomIconFont.isNullOrEmpty()) { + bottomDrawable = IconFontDrawable(context, bottomIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp)) + } + if (startIconFont == null && topIconFont == null && endIconFont == null && bottomIconFont == null) { + compoundDrawablePadding = 0 + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) + } else { + if (iconPadding != null) { + iconFontDrawablePadding = iconPadding.dp + compoundDrawablePadding = iconFontDrawablePadding + } else { + compoundDrawablePadding = iconFontDrawablePadding + } + setCompoundDrawablesRelative(startDrawable, topDrawable, endDrawable, bottomDrawable) + } + } + + private fun getFontColor(iconColorToken: String?): Int { + return if (iconFontColorToken.isNullOrBlank() && iconColorToken.isNullOrBlank()) { + currentTextColor + } else { + if (!iconColorToken.isNullOrBlank()) { + iconFontColorToken = iconColorToken + iconColorToken.handleUIToken(context)?.color ?: 0 + } else { + iconFontColorToken?.handleUIToken(context)?.color ?: 0 + } + } + } + + private fun getFontSize(iconSize: Int, isDp: Boolean): Int = if (iconSize == 0 && iconFontSize == 0) textSize.toInt() else { + if (iconSize != 0) { + if (isDp) iconSize.dp else iconSize + } else iconFontSize + } + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt new file mode 100644 index 0000000..df6a3f6 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/LikeView.kt @@ -0,0 +1,105 @@ +package com.remax.visualnovel.widget.ui + +import android.animation.Animator +import android.content.Context +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import androidx.core.view.isGone +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetItemLikeBinding +import com.remax.visualnovel.extension.changeLikedStatus +import com.remax.visualnovel.utils.EpalUtils +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.view.UITokenConstraintLayout +import com.dylanc.viewbinding.nonreflection.inflate + +/** + * Created by HJW on 2022/8/31 + * + */ +class LikeView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenConstraintLayout(context, attrs, defStyleAttr) { + + var binding: WidgetItemLikeBinding? = null + + companion object { + private const val DARK = 1 + private const val TRANSPARENT = 2 + } + + init { + binding = inflate(WidgetItemLikeBinding::inflate) + context.withStyledAttributes(attrs, R.styleable.LikeView) { + val bgType = getInt(R.styleable.LikeView_likeViewBg, DARK) + binding?.run { + root.changeBackground { + backgroundUIColorToken = context.getString( + when (bgType) { + TRANSPARENT -> { + R.string.color_transparent + } + + else -> { + R.string.color_surface_element_dark_normal + } + } + ) + + } + } + } + } + + fun changeLike(isLike: Boolean, isAnim: Boolean = false) { + binding?.run { + when { + !isLike -> { + widgetItemLottieLike.isVisible = false + widgetItemLikeIcon.changeLikedStatus(false) + } + + isAnim -> { + widgetItemLikeIcon.isGone = true + widgetItemLottieLike.run { + isVisible = true + progress = 0f + addAnimatorListener(object : Animator.AnimatorListener { + override fun onAnimationStart(p0: Animator) { + isEnabled = false + } + + override fun onAnimationEnd(p0: Animator) { + isEnabled = true + progress = 1f + } + + override fun onAnimationCancel(p0: Animator) { + + } + + override fun onAnimationRepeat(p0: Animator) { + + } + }) + playAnimation() + } + } + + else -> { + widgetItemLottieLike.isVisible = false + widgetItemLikeIcon.changeLikedStatus(true) + } + } + } + } + + fun setLikeNum(likedCount: Int?) { + setLikeNum(likedCount?.toLong()) + } + + fun setLikeNum(likedCount: Long?) { + binding?.widgetItemAlbumLikeNum?.text = EpalUtils.formatNumberAutoSize(likedCount ?: 0) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt new file mode 100644 index 0000000..3eae575 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioCheckButton.kt @@ -0,0 +1,47 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class RadioCheckButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + View(context, attrs, defStyleAttr) { + + private val viewSize = 20.dp + + var isChecked = false + private var normalDrawable = R.mipmap.radio_normal + + init { + context.withStyledAttributes(attrs, R.styleable.RadioCheckButton) { + isChecked = getBoolean(R.styleable.RadioCheckButton_radioCheck, false) + normalDrawable = getResourceId(R.styleable.RadioCheckButton_radioNormalDrawable, R.mipmap.radio_normal) + } + viewChecked(isChecked) + } + + fun viewChecked(isChecked: Boolean) { + this.isChecked = isChecked + if (isChecked) { + setBackgroundResource(R.mipmap.icon_checked) + } else { + setBackgroundResource(normalDrawable) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + setMeasuredDimension(widthMeasureSpec, heightMeasureSpec) + } else { + setMeasuredDimension(viewSize, viewSize) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt new file mode 100644 index 0000000..b021fb4 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/RadioOnPicButton.kt @@ -0,0 +1,61 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class RadioOnPicButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + var isChecked = false + + init { + context.withStyledAttributes(attrs, R.styleable.RadioOnPicButton) { + isChecked = getBoolean(R.styleable.RadioOnPicButton_radioCheck, false) + } + changeTextColor { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + } + changeTextFont { + textUITextToken = context.getString(R.string.txt_numMonotype_xs) + } + changeBackground { + radiusToken = context.getString(R.string.radius_pill) + } + viewChecked(isChecked) + height = 20.dp + width = 20.dp + gravity = Gravity.CENTER + } + + fun viewChecked(isChecked: Boolean) { + this.isChecked = isChecked + changeBackground { + backgroundUIColorToken = if (isChecked) { + context.getString(R.string.color_primary_normal) + } else { + context.getString(R.string.color_surface_element_dark_normal) + } + } + } + +// override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { +// super.onMeasure(widthMeasureSpec, heightMeasureSpec) +// if (View.MeasureSpec.getMode(widthMeasureSpec) == View.MeasureSpec.EXACTLY && View.MeasureSpec.getMode(heightMeasureSpec) == View.MeasureSpec.EXACTLY) { +// setMeasuredDimension(widthMeasureSpec, heightMeasureSpec) +// } else { +// setMeasuredDimension(viewSize, viewSize) +// } +// } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt new file mode 100644 index 0000000..2fe2a31 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/SwitchView.kt @@ -0,0 +1,182 @@ +package com.remax.visualnovel.widget.ui + + +import android.content.Context +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.util.AttributeSet +import androidx.annotation.StringRes +import androidx.appcompat.widget.SwitchCompat +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken + + +/** + * Created by HJW on 2022/9/12 + */ +class SwitchView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = androidx.appcompat.R.attr.switchStyle +) : + SwitchCompat(context, attrs, defStyleAttr) { + + companion object { + const val LARGE = 0 + const val SMALL = 1 + const val XSMALL = 2 + } + + private var bgWid = 0 + private var bgHei = 0 + private var thumbSize = 0 + private var padding = 0 + private var strokeWidth = 0 + + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.SwitchView) + val type = ta.getInt(R.styleable.SwitchView_switchSize, SMALL) + val colorStyle = ta.getInt(R.styleable.SwitchView_switchColorStyle, 0) + ta.recycle() + + when (type) { + LARGE -> { + bgWid = 56.dp + bgHei = 32.dp + thumbSize = 24.dp + padding = 4.dp + strokeWidth = 4.dp + } + + SMALL -> { + bgWid = 40.dp + bgHei = 24.dp + thumbSize = 16.dp + padding = 4.dp + strokeWidth = 4.dp + } + + XSMALL -> { + bgWid = 28.dp + bgHei = 16.dp + thumbSize = 12.dp + padding = 2.dp + strokeWidth = 1.dp + } + } + + background = setTrackDrawable(true, colorStyle) + //轨道背景 + trackDrawable = setTrackDrawable(false, colorStyle) + //滑块背景 + thumbDrawable = setThumbDrawable(colorStyle) + + setPadding(padding, padding, padding, padding) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(bgWid, bgHei) + } + + fun setPressChanged(callback: (Boolean) -> Unit) { + setOnCheckedChangeListener { v, checked -> + if (v.isPressed) { + callback(checked) + } + } + } + + private fun setThumbDrawable(style: Int): Drawable { + val normal: Int + val checked: Int + if (style == 0) { + normal = R.string.color_surface_white_normal + checked = R.string.color_surface_white_normal + } else { + normal = R.string.color_txt_secondary_normal + checked = R.string.color_surface_white_normal + } + val normalDrawable = getThumbStateDrawable(normal) + //val pressDrawable = getThumbStateDrawable(R.string.color_surface_white_press, R.string.color_surface_element_press) + //val disabledDrawable = getThumbStateDrawable(R.string.color_surface_white_disabled, R.string.color_surface_element_normal) + val checkedDrawable = getThumbStateDrawable(checked) + + val resDrawable = StateListDrawable() + //resDrawable.addState(intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled), pressDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_checked, android.R.attr.state_enabled), checkedDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_enabled), normalDrawable) + //resDrawable.addState(intArrayOf(), disabledDrawable) + + return resDrawable + } + + private fun getThumbStateDrawable(@StringRes colorToken: Int): Drawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setSize(thumbSize, thumbSize) + val color = context.handleUIToken(colorToken)?.color ?: 0 + setColor(color) + } + } + + private fun getTrackStateDrawable( + @StringRes colorToken: Int, + @StringRes strokeColorToken: Int, + isBg: Boolean + ): Drawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + setSize(if (isBg) bgWid else bgWid - 8.dp, if (isBg) bgHei else bgHei - 8.dp) + val radius = context.handleUIToken(R.string.radius_pill)?.size ?: 0f + cornerRadius = radius + val color = context.handleUIToken(colorToken)?.color ?: 0 + val strokeWidth = if (isBg) strokeWidth else 0 + val strokeColor = context.handleUIToken(strokeColorToken)?.color ?: 0 + setStroke(strokeWidth, strokeColor) + setColor(color) + } + } + + private fun setTrackDrawable(isBg: Boolean, style: Int): Drawable { + val normal: Int + val checked: Int + val stroke: Int + if (style == 0) { + if (isBg) { + normal = R.string.color_transparent + checked = R.string.color_primary_normal + stroke = R.string.color_surface_element_normal + } else { + normal = R.string.color_surface_element_normal + checked = R.string.color_transparent + stroke = R.string.color_transparent + } + } else { + if (isBg) { + normal = R.string.color_surface_top_normal + checked = R.string.color_primary_normal + stroke = R.string.color_outline_normal + } else { + normal = R.string.color_surface_top_normal + checked = R.string.color_primary_normal + stroke = R.string.color_transparent + } + } + val normalDrawable = getTrackStateDrawable(normal, stroke, isBg) + //val pressDrawable = getTrackStateDrawable(R.string.color_surface_element_press, isBg) + //val disabledDrawable = getTrackStateDrawable(R.string.color_surface_element_normal, isBg) + val checkedDrawable = getTrackStateDrawable(checked, checked, isBg) + + val resDrawable = StateListDrawable() + //resDrawable.addState(intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled), pressDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_checked, android.R.attr.state_enabled), checkedDrawable) + resDrawable.addState(intArrayOf(android.R.attr.state_enabled), normalDrawable) + //resDrawable.addState(intArrayOf(), disabledDrawable) + return resDrawable + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt new file mode 100644 index 0000000..0f27f50 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/UserAvatarView.kt @@ -0,0 +1,172 @@ +package com.remax.visualnovel.widget.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.AppCompatImageView +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetUserAvatarBinding +import com.remax.visualnovel.extension.glide.loadAndCircleCrop +import com.remax.visualnovel.extension.glide.loadCircleAvatar +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.extension.setSize +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.imageviewer.viewer.ViewerTransitionHelper +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.view.UITokenConstraintLayout +import com.dylanc.viewbinding.nonreflection.inflate + + +/** + * Created by HJW on 2022/8/31 + * + */ +class UserAvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenConstraintLayout(context, attrs, defStyleAttr) { + + companion object { + const val XXL = 0 + const val XL = 1 + const val L = 2 + const val M = 3 + const val S = 4 + + const val BORDER_WHITE = 1 + const val BORDER_BLACK = 2 + } + + private var binding: WidgetUserAvatarBinding? = null + + private var avatarSize: Int = 0 + private var avatarFrameSize: Int = 0 + private var onlineStatusSize: Int = 0 + private var avatarSizeType: Int = 0 + + private val sizeXXL = 128 + private val sizeFrameXXL = 160 + private val sizeXL = 80 + private val sizeFrameXL = 104 + private val sizeL = 64 + private val sizeFrameL = 88 + private val sizeM = 48 + private val sizeFrameM = 64 + private val sizeS = 32 + private val sizeFrameS = 40 + + init { + binding = inflate(WidgetUserAvatarBinding::inflate) + val ta = context.obtainStyledAttributes(attrs, R.styleable.UserAvatarView) + val avatarBorder = ta.getBoolean(R.styleable.UserAvatarView_avatarBorder, false) + val avatarBorderType = ta.getInt(R.styleable.UserAvatarView_avatarBorderType, BORDER_WHITE) + avatarSize = ta.getDimensionPixelSize(R.styleable.UserAvatarView_avatarSize, 0) + avatarFrameSize = ta.getDimensionPixelSize(R.styleable.UserAvatarView_avatarFrameSize, 0) + onlineStatusSize = ta.getDimensionPixelSize(R.styleable.UserAvatarView_onlineStatusSize, 0) + avatarSizeType = ta.getInt(R.styleable.UserAvatarView_avatarSizeType, -1) + ta.recycle() + binding?.run { + var avatarBorderPadding = 0.dp + when (avatarSizeType) { + XXL -> { + avatarSize = sizeXXL.dp + avatarFrameSize = sizeFrameXXL.dp + onlineStatusSize = 24.dp + } + + XL -> { + avatarBorderPadding = 2.dp + avatarSize = sizeXL.dp + avatarFrameSize = sizeFrameXL.dp + onlineStatusSize = 16.dp + } + + L -> { + avatarSize = sizeL.dp + avatarFrameSize = sizeFrameL.dp + onlineStatusSize = 16.dp + } + + M -> { + avatarSize = sizeM.dp + avatarFrameSize = sizeFrameM.dp + onlineStatusSize = 8.dp + } + + S -> { + avatarBorderPadding = 1.dp + avatarSize = sizeS.dp + avatarFrameSize = sizeFrameS.dp + onlineStatusSize = 8.dp + } + } + if (avatarBorder) { + userAvatarIv.setPadding(avatarBorderPadding, avatarBorderPadding, avatarBorderPadding, avatarBorderPadding) + userAvatarIv.changeBackground { + when (avatarBorderType) { + BORDER_WHITE -> backgroundUIColorToken = context.getString(R.string.color_surface_white_normal) + BORDER_BLACK -> backgroundUIColorToken = context.getString(R.string.color_background_default) + } + } + } + userAvatarIv.setSize(avatarSize, avatarSize) + userAvatarFrameIv.setSize(avatarFrameSize, avatarFrameSize) + userAvatarOnlineStatus.setSize(onlineStatusSize, onlineStatusSize) + + root.setSize(avatarSize, avatarSize) + + clipChildren = false + clipToPadding = false + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(avatarSize, avatarSize) + (parent as? ViewGroup)?.clipChildren = false + (parent as? ViewGroup)?.clipToPadding = false + } + + fun getAvatarImageView(): AppCompatImageView? = binding?.userAvatarIv + + /** + * needViewer 能否点击放大 + */ + fun loadAvatar(avatarUrl: String?, clickInvoke: ((View) -> Unit)? = null) { + binding?.userAvatarIv?.let { + if (avatarUrl?.startsWith("http") == true){ + it.loadCircleAvatar(avatarUrl, avatarSize) + }else{ + it.loadAndCircleCrop(avatarUrl) + } + if (clickInvoke != null) { + ViewerTransitionHelper.put(it) + setOnClick(it) { + clickInvoke.invoke(it) + } + } + } + } + + fun loadImageResourceAvatar(resId: Int) { + binding?.userAvatarIv?.setImageResource(resId) + } + + fun setAvatarViewPadding(padding: Int) { + binding?.userAvatarIv?.setPadding(padding,padding,padding,padding) + } + +// /** +// * 隐藏/显示在线状态 +// */ +// fun onlineStatusIsShow(isShow: Boolean) { +// binding?.userAvatarOnlineStatus?.isVisible = isShow +// } + +// /** +// * 展示头像框 +// */ +// fun showAvatarFrame(avatarFrame: String?, withHost: Boolean = true) { +// binding?.userAvatarFrameIv?.showVipAvatar(avatarFrame, withHost) +// } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt new file mode 100644 index 0000000..7435000 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/NumberIndicator.kt @@ -0,0 +1,69 @@ +package com.remax.visualnovel.widget.ui.bannerindicator + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getTextFontTypeface +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.youth.banner.indicator.BaseIndicator +import kotlin.math.ceil + +/** + * Created by HJW on 2022/9/8 + */ +class NumberIndicator @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + BaseIndicator(context, attrs, defStyleAttr) { + + private val rectF = RectF() + + private val textFont = context.handleUIToken(R.string.txt_label_s)?.textFont + private val textColor = context.handleUIToken(R.string.color_txt_primary_normal)?.color ?: 0 + + private val bgColor = context.handleUIToken(R.string.color_surface_element_disabled)?.color ?: 0 + + private val radius = context.handleUIToken(R.string.radius_xs)?.size ?: 0f + + private val viewHeight = 24.dp.toFloat() + private var viewWidth = 0f + + init { + mPaint.run { + textFont?.let { + typeface = context.getTextFontTypeface(it.typeFace) + textSize = it.textFontSize + } + textAlign = Paint.Align.CENTER + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val count = config.indicatorSize + if (count <= 1) { + return + } + viewWidth = mPaint.measureText("$count/$count") + 16.dp + setMeasuredDimension(ceil(viewWidth).toInt(), ceil(viewHeight).toInt()) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val count = config.indicatorSize + if (count <= 1) { + return + } + val content = "${config.currentPosition + 1}/$count" + rectF.set(0f, 0f, viewWidth, viewHeight) + mPaint.color = bgColor + canvas.drawRoundRect(rectF, radius, radius, mPaint) + mPaint.color = textColor + val fontMetrics = mPaint.fontMetrics + val distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom + val baseline = rectF.centerY() + distance * 1.5f + canvas.drawText(content, rectF.centerX(), baseline, mPaint) + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt new file mode 100644 index 0000000..4f75707 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/bannerindicator/RectangleIndicator.kt @@ -0,0 +1,82 @@ +package com.remax.visualnovel.widget.ui.bannerindicator + +import android.content.Context +import android.graphics.Canvas +import android.graphics.RectF +import android.util.AttributeSet +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.handleUIToken +import com.youth.banner.indicator.BaseIndicator +import kotlin.math.ceil + +/** + * Created by HJW on 2022/9/8 + */ +class RectangleIndicator @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + BaseIndicator(context, attrs, defStyleAttr) { + + companion object { + const val XSMALL = 1 + const val SMALL = 2 + } + + private val rectF = RectF() + + private val selectColor = context.handleUIToken(R.string.color_surface_white_normal)?.color ?: 0 + private val normalColor = context.handleUIToken(R.string.color_surface_white_disabled)?.color ?: 0 + + private val radius = context.handleUIToken(R.string.radius_pill)?.size ?: 0f + + private var singleWidth = 4.dp.toFloat() + private var viewHeight = 4.dp.toFloat() + private var viewSpace = 3.dp.toFloat() + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.RectangleIndicator) + val type = ta.getInt(R.styleable.RectangleIndicator_rectangleSize, XSMALL) + ta.recycle() + when (type) { + XSMALL -> { + singleWidth = 4.dp.toFloat() + viewHeight = 4.dp.toFloat() + viewSpace = 3.dp.toFloat() + } + + SMALL -> { + singleWidth = 8.dp.toFloat() + viewHeight = 8.dp.toFloat() + viewSpace = 4.dp.toFloat() + } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val count = config.indicatorSize + if (count <= 1) { + return + } + //间距*(总数-1)+默认宽度*(总数-1)+选中宽度 + val space = viewSpace * (count - 1) + val normal = singleWidth * count + val wid = ceil(space + normal).toInt() + setMeasuredDimension(wid, ceil(viewHeight).toInt()) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val count = config.indicatorSize + if (count <= 1) { + return + } + var left = 0f + for (i in 0 until count) { + mPaint.color = if (config.currentPosition == i) selectColor else normalColor + val indicatorWidth = singleWidth + rectF.set(left, 0f, left + indicatorWidth, viewHeight) + left += indicatorWidth + viewSpace + canvas.drawRoundRect(rectF, radius, radius, mPaint) + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt new file mode 100644 index 0000000..0200111 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/ButtonView.kt @@ -0,0 +1,289 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import androidx.core.widget.TextViewCompat +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * + */ +class ButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val DefaultButton_Primary = 0 + const val DefaultButton_Secondary = 1 + const val DefaultButton_Tertiary = 2 + const val DefaultButton_Destructive = 3 + const val ContrastButton_Primary = 4 + const val ContrastButton_Secondary = 5 + const val ContrastButton_Tertiary_Light = 6 + const val ContrastButton_Tertiary_Dark = 7 + const val GhostButton_Primary = 8 + const val GhostButton_Secondary = 9 + const val ContextButton_Subscribe = 10 + const val IconButton_Primary = 11 + const val IconButton_Tertiary = 12 + const val DefaultButton_VIP = 13 + + const val LARGE = 0 + const val SMALL = 1 + } + + private var sizeType = -1 + private var buttonName = -1 + + private var overrideAttr = false + + init { + context.withStyledAttributes(attrs, R.styleable.ButtonView) { + sizeType = getInt(R.styleable.ButtonView_buttonSizeType, -1) + buttonName = getInt(R.styleable.ButtonView_buttonName, -1) + overrideAttr = getBoolean(R.styleable.ButtonView_buttonOverrideAttr, false) + } + customViewToken.run { + radiusToken = context.getString(R.string.radius_pill) + } + setButtonStyle(sizeType, buttonName) + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + + runCatching { + val minSize = 10.dp + val maxSize = textSize.toInt() + if (minSize < maxSize) { + TextViewCompat.setAutoSizeTextTypeWithDefaults(this, TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM) + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(this, minSize, maxSize, 2, TypedValue.COMPLEX_UNIT_PX) + } + } + + } + + fun setButtonStyle(buttonName: Int) { + setButtonStyle(sizeType, buttonName) + } + + fun getMeasureText() = paint.measureText(text.toString()) + paddingStart + paddingEnd + + fun setButtonStyle(sizeType: Int, buttonName: Int) { + this.buttonName = buttonName + customViewToken.run { + val isIconFont = buttonName == IconButton_Primary || buttonName == IconButton_Tertiary + /** + * 设置size大小 + */ + when (sizeType) { + LARGE -> { + height = 48.dp + val padding = 24.dp + setPadding(padding, 0, padding, 0) + if (isIconFont) { + typeface = context.getIconFontType() + textSize = 24f + } else { + textUITextToken = context.getString(R.string.txt_label_l) + } + } + + SMALL -> { + height = 32.dp + val padding = 16.dp + setPadding(padding, 0, padding, 0) + if (minWidth == 0) { + minWidth = 60.dp + } + if (isIconFont) { + typeface = context.getIconFontType() + textSize = 20f + } else { + textUITextToken = context.getString(R.string.txt_label_s) + } + } + } + /** + * 设置state颜色/描边 + */ + when (buttonName) { + DefaultButton_VIP -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_context_vip_normal) + backgroundUIPressedColorToken = "" + backgroundUIHoveredColorToken = "" + backgroundUIDisabledColorToken = "" + } + + DefaultButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_gradient_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_txt_primary_normal) + backgroundUIDisabledColorToken = context.getString(R.string.color_primary_disabled) + } + + DefaultButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + textUIHintColorToken = context.getString(R.string.color_primary_variant_hover) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_transparent) + backgroundUIHoveredColorToken = context.getString(R.string.color_transparent) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + + strokeUIWidthToken = context.getString(R.string.border_m) + strokeUIColorToken = context.getString(R.string.color_primary_variant_normal) + strokeUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + strokeUIHoveredColorToken = context.getString(R.string.color_primary_variant_hover) + strokeUIDisabledColorToken = context.getString(R.string.color_primary_variant_disabled) + } + + DefaultButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + + DefaultButton_Destructive -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_important_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_important_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_important_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_important_disabled) + } + + ContrastButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_base_specialmap_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_base_specialmap_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_base_specialmap_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_base_specialmap_disabled) + } + + ContrastButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_surface_white_normal) + textUIPressedColorToken = context.getString(R.string.color_surface_white_press) + textUIHintColorToken = context.getString(R.string.color_surface_white_hover) + textUIDisabledColorToken = context.getString(R.string.color_surface_white_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_transparent) + backgroundUIHoveredColorToken = context.getString(R.string.color_transparent) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + + strokeUIWidthToken = context.getString(R.string.border_m) + strokeUIColorToken = context.getString(R.string.color_surface_white_normal) + strokeUIPressedColorToken = context.getString(R.string.color_surface_white_press) + strokeUIHoveredColorToken = context.getString(R.string.color_surface_white_hover) + strokeUIDisabledColorToken = context.getString(R.string.color_surface_white_disabled) + } + + ContrastButton_Tertiary_Light -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_light_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_light_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_light_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_light_disabled) + } + + ContrastButton_Tertiary_Dark -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_dark_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_dark_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_dark_disabled) + } + + GhostButton_Primary -> { + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + GhostButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + ContextButton_Subscribe -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_context_subscribe_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_context_subscribe_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_context_subscribe_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_context_subscribe_disabled) + } + + IconButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_primary_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_primary_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_primary_disabled) + } + + IconButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + } + if (overrideAttr) { + obtainStyledAttributes() + } + if (!isIconFont) { + changeTextFont(this) + } + changeTextColor(this) + changeBackground(this) + changeOutline(this) + } + + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt new file mode 100644 index 0000000..6e95dde --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/FloatActionButtonView.kt @@ -0,0 +1,46 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +/** + * Created by HJW on 2022/8/31 + * + */ +class FloatActionButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + init { + textSize = 24f + + val clickExpend = 24.dp + expand(clickExpend, clickExpend) + + customViewToken.apply { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_gradient_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_primary_gradient_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_primary_gradient_disabled) + radiusToken = context.getString(R.string.radius_pill) + } + + changeBackground(customViewToken) + changeTextColor(customViewToken) + + gravity = Gravity.CENTER + typeface = context.getIconFontType() + + height = 56.dp + width = 56.dp + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt new file mode 100644 index 0000000..2d67657 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/IconButtonView.kt @@ -0,0 +1,275 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import androidx.core.content.withStyledAttributes +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +/** + * Created by HJW on 2022/8/31 + * + */ +class IconButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val DefaultIconButton_Primary = 0 + const val DefaultIconButton_Secondary = 1 + const val DefaultIconButton_Tertiary = 2 + const val GhostIconButton = 3 + const val GhostIconButton_Secondary = 4 + const val ContrastButton_Secondary = 5 + const val ContrastButton_Tertiary_Dark = 6 + const val ContrastButton_Tertiary_Light = 7 + const val NavButton = 8 + const val NavButton_OnPic = 9 + + const val LARGE = 0 + const val MEDIUM = 1 + const val SMALL = 2 + const val XSMALL = 3 + const val XXSMALL = 4 + + const val RECTANGLE = 0 + const val ROUND = 1 + } + + private var sizeType = -1 + private var radiusType = -1 + private var buttonName = -1 + + private var overrideAttr = false + + init { + context.withStyledAttributes(attrs, R.styleable.IconButtonView) { + sizeType = getInt(R.styleable.IconButtonView_iconButtonSizeType, -1) + radiusType = getInt(R.styleable.IconButtonView_iconButtonRadius, -1) + buttonName = getInt(R.styleable.IconButtonView_iconButtonName, -1) + overrideAttr = getBoolean(R.styleable.IconButtonView_buttonOverrideAttr, false) + } + gravity = Gravity.CENTER + typeface = context.getIconFontType() + setButtonStyle(sizeType, buttonName) + } + + fun setButtonStyle(sizeType: Int = this.sizeType, buttonName: Int, radiusType: Int = this.radiusType) { + customViewToken.run { + var isSecondary = false + var isNav = false + + when (radiusType) { + RECTANGLE -> { + radiusToken = context.getString(R.string.radius_s) + } + + ROUND -> { + radiusToken = context.getString(R.string.radius_pill) + } + } + + when (buttonName) { + DefaultIconButton_Primary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_disabled) + + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_primary_gradient_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_primary_gradient_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + + DefaultIconButton_Secondary -> { + isSecondary = true + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + textUIHoveredColorToken = context.getString(R.string.color_primary_variant_hover) + textUIDisabledColorToken = context.getString(R.string.color_primary_variant_disabled) + } + + DefaultIconButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_disabled) + } + + GhostIconButton -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + GhostIconButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_txt_secondary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_secondary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + ContrastButton_Secondary -> { + isSecondary = true + textUIColorToken = context.getString(R.string.color_surface_white_normal) + textUIPressedColorToken = context.getString(R.string.color_surface_white_press) + textUIHoveredColorToken = context.getString(R.string.color_surface_white_hover) + textUIDisabledColorToken = context.getString(R.string.color_surface_white_disabled) + } + + ContrastButton_Tertiary_Dark -> { + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_dark_press) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_dark_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_dark_disabled) + } + + ContrastButton_Tertiary_Light -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_light_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_light_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_light_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_light_disabled) + } + + NavButton -> { + isNav = true + radiusToken = context.getString(R.string.radius_pill) + + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + + backgroundUIColorToken = context.getString(R.string.color_transparent) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_transparent) + } + + NavButton_OnPic -> { + isNav = true + radiusToken = context.getString(R.string.radius_pill) + + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_specialmap_disabled) + + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + backgroundUIPressedColorToken = context.getString(R.string.color_surface_element_dark_press) + backgroundUIHoveredColorToken = context.getString(R.string.color_surface_element_dark_hover) + backgroundUIDisabledColorToken = context.getString(R.string.color_surface_element_dark_disabled) + } + } + + when (sizeType) { + LARGE -> { + textSize = if (isSecondary) 48f else 24f +// height = if (isSecondary) 48.dp else 40.dp +// width = if (isSecondary) 48.dp else 40.dp + height = 48.dp + width = 48.dp + + val expendSize = when { + isSecondary -> { + 0 + } + + isNav -> { + 2.dp + } + + else -> { + 4.dp + } + } + xClickExpend = expendSize + yClickExpend = expendSize + } + + MEDIUM -> { + textSize = if (isSecondary) 36f else 20f + height = 36.dp + width = 36.dp + val expendSize = when { + isNav -> { + 4.dp + } + + else -> { + 6.dp + } + } + xClickExpend = expendSize + yClickExpend = expendSize + + } + + SMALL -> { + textSize = if (isSecondary) 32f else 16f + height = 32.dp + width = 32.dp + val expendSize = when { + isNav -> { + 6.dp + } + + else -> { + 8.dp + } + } + xClickExpend = expendSize + yClickExpend = expendSize + } + + XSMALL -> { + textSize = if (isSecondary) 24f else 12f + height = 24.dp + width = 24.dp + val expendSize = 10.dp + xClickExpend = expendSize + yClickExpend = expendSize + } + + XXSMALL -> { + textSize = if (isSecondary) 16f else 8f + height = 16.dp + width = 16.dp + val expendSize = 14.dp + xClickExpend = expendSize + yClickExpend = expendSize + } + } + + if (overrideAttr) { + obtainStyledAttributes() + } + + changeTextFont(this) + changeTextColor(this) + changeBackground(this) + changeOutline(this) + if (xClickExpend != 0 || yClickExpend != 0) { + expand(xClickExpend, yClickExpend) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt new file mode 100644 index 0000000..13479aa --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/buttons/TextButtonView.kt @@ -0,0 +1,91 @@ +package com.remax.visualnovel.widget.ui.buttons + +import android.content.Context +import android.graphics.Paint +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +/** + * Created by HJW on 2022/8/31 + * + */ +class TextButtonView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val TextButton_Primary = 0 + const val TextButton_Secondary = 1 + const val TextButton_Tertiary = 2 + + const val LARGE = 0 + const val MEDIUM = 1 + const val SMALL = 2 + } + + private var sizeType = -1 + private var buttonName = -1 + + init { + context.withStyledAttributes(attrs, R.styleable.TextButtonView) { + sizeType = getInt(R.styleable.TextButtonView_textButtonSizeType, -1) + buttonName = getInt(R.styleable.TextButtonView_textButtonName, -1) + val isUnderLine = getBoolean(R.styleable.TextButtonView_textButtonUnderLine,false) + if (isUnderLine){ + paint.flags = Paint.UNDERLINE_TEXT_FLAG + paint.isAntiAlias = true + } + } + setButtonStyle(sizeType, buttonName) + } + + fun setButtonStyle(sizeType: Int, buttonName: Int) { + customViewToken.run { + when (sizeType) { + LARGE -> { + textUITextToken = context.getString(R.string.txt_label_l) + } + + MEDIUM -> { + textUITextToken = context.getString(R.string.txt_label_m) + } + + SMALL -> { + textUITextToken = context.getString(R.string.txt_label_s) + } + } + + when (buttonName) { + TextButton_Primary -> { + textUIColorToken = context.getString(R.string.color_primary_variant_normal) + textUIDisabledColorToken = context.getString(R.string.color_txt_primary_disabled) + textUIPressedColorToken = context.getString(R.string.color_primary_variant_press) + textUIHintColorToken = context.getString(R.string.color_primary_variant_hover) + } + + TextButton_Secondary -> { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + textUIPressedColorToken = context.getString(R.string.color_txt_primary_press) + textUIHintColorToken = context.getString(R.string.color_txt_primary_hover) + } + + TextButton_Tertiary -> { + textUIColorToken = context.getString(R.string.color_txt_secondary_normal) + textUIPressedColorToken = context.getString(R.string.color_txt_secondary_press) + textUIHintColorToken = context.getString(R.string.color_txt_secondary_hover) + } + } + changeTextFont(this) + changeTextColor(this) + changeOutline(this) + if (xClickExpend != 0 || yClickExpend != 0) { + expand(xClickExpend, yClickExpend) + } + } + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt new file mode 100644 index 0000000..1d01253 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/ButtonIconExt.kt @@ -0,0 +1,50 @@ +package com.remax.visualnovel.widget.ui.lock + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.ButtonVipWithIconfontBinding +import com.remax.visualnovel.databinding.ButtonWithIconBinding +import com.remax.visualnovel.extension.setOnClick +import com.remax.visualnovel.widget.uitoken.changeBackground + +/** + * Created by HJW on 2025/8/15 + */ + +fun ButtonWithIconBinding.setTxtAndClick( + content: String?, + @DrawableRes iconRes: Int = R.mipmap.icon_diamond, + isEnabled:Boolean = true, + callback: () -> Unit +) { + buttonText.text = content + buttonText.isEnabled = isEnabled + + buttonIcon.isVisible = iconRes != 0 + if (iconRes != 0) { + buttonIcon.setImageResource(iconRes) + } + + button.isEnabled = isEnabled + setOnClick(button) { + callback() + } +} + +fun ButtonVipWithIconfontBinding.setTxtAndClick( + content: String?, + @StringRes radiusRes: Int? = null, + callback: () -> Unit +) { + vipText.text = content + if (radiusRes != null) { + vipBtn.changeBackground { + radiusToken = root.context.getString(radiusRes!!) + } + } + setOnClick(vipBtn) { + callback() + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt new file mode 100644 index 0000000..961494a --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockTagView.kt @@ -0,0 +1,95 @@ +package com.remax.visualnovel.widget.ui.lock + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import com.remax.visualnovel.R +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView + +fun String?.getLockLabel() = if (LoginManager.isMyself(this)) LockTagView.PRIVATE_LABEL else LockTagView.PUBLIC_LABEL + +/** + * Created by HJW on 2022/8/31 + * + */ +class LockTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenTextView(context, attrs, defStyleAttr) { + + companion object { + const val ALL = 1 + const val LTRB = 2 + + const val PUBLIC_LABEL = 1 + const val PRIVATE_LABEL = 2 + } + + private var lockType = ALL + private var lockLabel = PUBLIC_LABEL + + init { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LockTagView) + val type = typedArray.getInt(R.styleable.LockTagView_lockTagType, ALL) + val label = typedArray.getInt(R.styleable.LockTagView_lockTagLabel, PUBLIC_LABEL) + typedArray.recycle() + setRadiusType(type) + setLockLabel(label) + + textSize = 12f + val padding = 6.dp + setPadding(padding, padding, padding, padding) + gravity = Gravity.CENTER + changeTextFont() { + textUIColorToken = context.getString(R.string.color_txt_primary_normal) + onlyIconFont = true + } + } + + fun setLockLabel(lockLabel: Int) { + this.lockLabel = lockLabel + when (this@LockTagView.lockLabel) { + PUBLIC_LABEL -> { + setText(R.string.icon_public) + changeBackground { + backgroundUIColorToken = context.getString(R.string.color_primary_gradient_normal) + } + } + + PRIVATE_LABEL -> { + setText(R.string.icon_private) + changeBackground { + backgroundUIColorToken = context.getString(R.string.color_surface_element_dark_normal) + } + } + } + } + + private fun setRadiusType(lockType: Int) { + this.lockType = lockType + when (this@LockTagView.lockType) { + ALL -> { + changeBackground { + val radius = context.getString(R.string.radius_xs) + radiusToken = radius + topRightRadiusToken = radius + topLeftRadiusToken = radius + bottomRightRadiusToken = radius + bottomLeftRadiusToken = radius + } + } + + LTRB -> { + changeBackground { + radiusToken = "" + val radius = context.getString(R.string.radius_l) + bottomRightRadiusToken = radius + topLeftRadiusToken = radius + } + } + } + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt new file mode 100644 index 0000000..672a68e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/lock/LockViewGroup.kt @@ -0,0 +1,112 @@ +package com.remax.visualnovel.widget.ui.lock + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.remax.visualnovel.R +import com.remax.visualnovel.databinding.WidgetLockTagBinding +import com.remax.visualnovel.extension.formatPrice +import com.remax.visualnovel.extension.setMargin +import com.remax.visualnovel.manager.login.LoginManager +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.view.UITokenLinearLayout +import com.dylanc.viewbinding.nonreflection.inflate + +/** + * Created by HJW on 2022/8/31 + * + */ +class LockViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + UITokenLinearLayout(context, attrs, defStyleAttr) { + + private var binding: WidgetLockTagBinding? = null + + private val small = 1 + private val large = 2 + + init { + binding = inflate(WidgetLockTagBinding::inflate) + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LockViewGroup) + val radiusToken = typedArray.getString(R.styleable.LockViewGroup_radiusToken) ?: "" + val size = typedArray.getInt(R.styleable.LockViewGroup_lockViewGroupSize, small) + typedArray.recycle() + binding?.run { + group.changeBackground { + this.radiusToken = radiusToken + } + setSize(size) + } + } + + fun setSize(size: Int) { + binding?.run { + when (size) { + small -> { + unlockGroup.isVisible = false + with(lockIv) { + textSize = 24f + updateLayoutParams { + bottomToTop = R.id.lockTv + bottomToBottom = ConstraintLayout.LayoutParams.UNSET + } + setMargin(bottomMargin = 6.dp) + } + with(lockTv) { + isVisible = true + setSizeType(R.string.txt_label_m, 16.dp) + setMargin(bottomMargin = 0, topMargin = 6.dp) + updateLayoutParams { + topToBottom = R.id.lockIv + bottomToTop = ConstraintLayout.LayoutParams.UNSET + } + } + } + + large -> { + unlockGroup.isVisible = true + with(lockIv) { + textSize = 40f + updateLayoutParams { + bottomToTop = ConstraintLayout.LayoutParams.UNSET + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + } + setMargin(bottomMargin = 0.dp) + } + with(lockTv) { + isVisible = false + setSizeType(R.string.txt_label_l, 16.dp) + setMargin(bottomMargin = 24.dp, topMargin = 0) + updateLayoutParams { + topToBottom = ConstraintLayout.LayoutParams.UNSET + bottomToTop = R.id.unlockGroup + } + } + } + } + } + } + + /*fun setMyBalance() { + // 我的钱包余额 + binding?.unlockPriceView?.setPrice(formatPrice(WalletManager.balance)) + }*/ + + @SuppressLint("SetTextI18n") + fun setPreviewUnlockInfo(unlockPrice: Long?, clickUnlock: (() -> Unit)? = null) { + binding?.let { + val priceTxt = "${formatPrice(unlockPrice)} ${context.getString(R.string.unlock)}" + it.lockTv.setPrice(priceTxt) + it.buttonIcon.setTxtAndClick(priceTxt) { + LoginManager.checkLogin { + clickUnlock?.invoke() + } + } + } + } + + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt new file mode 100644 index 0000000..a105c82 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ColorSupportTagView.kt @@ -0,0 +1,105 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.tags.constant.TagSize +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class ColorSupportTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val DEFAULT = 0 + const val PRIMARY = 1 + const val WARNING = 2 + const val POSITIVE = 3 + const val EMPHASIS = 4 + const val IMPORTANT = 5 + } + + private var sizeType = TagSize.SMALL + + init { + includeFontPadding = false + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ColorSupportTagView) + sizeType = typedArray.getInt(R.styleable.ColorSupportTagView_tagViewSize, TagSize.SMALL) + val colorType = typedArray.getInt(R.styleable.ColorSupportTagView_tagBackgroundColorType, -1) + val background = typedArray.getString(R.styleable.ColorSupportTagView_tagBackgroundColorToken) ?: "" + typedArray.recycle() + val padding = 8.dp + setPadding(padding, 0, padding, 0) + setTagType(sizeType, background, colorType) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + gravity = Gravity.CENTER + } + + fun setTagType(type: Int = sizeType, colorToken: String = "", colorType: Int = -1) { + var textUITextToken = "" + when (type) { + TagSize.SMALL -> { + textUITextToken = context.getString(R.string.txt_label_s) + height = 24.dp + } + + TagSize.LARGE -> { + textUITextToken = context.getString(R.string.txt_label_m) + height = 32.dp + } + } + + val token = CustomViewToken( + textUITextToken = textUITextToken, + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal), + radiusToken = context.getString(R.string.radius_xs) + ) + val backgroundUIColorToken = when (colorType) { + DEFAULT -> { + token.textUIColorToken = context.getString(R.string.color_txt_primary_normal) + context.getString(R.string.color_surface_element_normal) + } + + PRIMARY -> { + context.getString(R.string.color_primary_normal) + } + + WARNING -> { + context.getString(R.string.color_warning_normal) + } + + POSITIVE -> { + context.getString(R.string.color_positive_normal) + } + + EMPHASIS -> { + context.getString(R.string.color_emphasis_normal) + } + + IMPORTANT -> { + context.getString(R.string.color_important_normal) + } + + else -> { + colorToken + } + } + token.backgroundUIColorToken = backgroundUIColorToken + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt new file mode 100644 index 0000000..98c38d2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/ContextTagView.kt @@ -0,0 +1,88 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class ContextTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val PRICE_PROMOTION = 1 + const val SUBSCRIBE = 2 + const val LEGENDS = 3 + const val LEGENDS_B = 4 + } + + init { + includeFontPadding = false + gravity = Gravity.CENTER + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ContextTagView) + val type = typedArray.getInt(R.styleable.ContextTagView_contextTagType, PRICE_PROMOTION) + typedArray.recycle() + val padding = 8.dp + setPadding(padding, 0, padding, 0) + height = 24.dp + setTagType(type) + gravity = Gravity.CENTER_VERTICAL + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + + fun setTagType(type: Int) { + val token = CustomViewToken( + textUITextToken = context.getString(R.string.txt_label_s), + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal), + radiusToken = context.getString(R.string.radius_xs) + ) + val colorToken = when (type) { + PRICE_PROMOTION -> { + context.getString(R.string.color_primary_gradient_normal) + } + + SUBSCRIBE -> { + context.getString(R.string.color_context_subscribe_normal) + } + + LEGENDS -> { + token.apply { + strokeUIWidthToken = context.getString(R.string.border_s) + strokeUIColorToken = context.getString(R.string.color_context_legends_normal) + textUIColorToken = context.getString(R.string.color_context_legends_normal) + } + context.getString(R.string.color_surface_element_dark_normal) + } + + LEGENDS_B -> { + token.apply { + strokeUIWidthToken = context.getString(R.string.border_s) + strokeUIColorToken = context.getString(R.string.color_context_legends_variant_normal) + textUIColorToken = context.getString(R.string.color_context_legends_variant_normal) + } + context.getString(R.string.color_surface_element_dark_normal) + } + + else -> { + context.getString(R.string.color_transparent) + } + } + token.backgroundUIColorToken = colorToken + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt new file mode 100644 index 0000000..9d16b08 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/OfficialTagView.kt @@ -0,0 +1,73 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class OfficialTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val OFFICIAL = 1 + const val HOST = 2 + const val VIP_SERVICE = 3 + } + + init { + includeFontPadding = false + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.OfficialTagView) + val type = typedArray.getInt(R.styleable.OfficialTagView_officialTagType, OFFICIAL) + typedArray.recycle() + val padding = 8.dp + setPaddingRelative(padding, 0, padding, 0) + height = 24.dp + setTagType(type) + gravity = Gravity.CENTER_VERTICAL + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + + fun setTagType(type: Int) { + var colorToken = "" + var textUIColorToken = "" + when (type) { + OFFICIAL -> { + colorToken = context.getString(R.string.color_primary_normal) + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + } + + HOST -> { + colorToken = context.getString(R.string.color_primary_normal) + textUIColorToken = context.getString(R.string.color_txt_primary_specialmap_normal) + } + + VIP_SERVICE -> { + colorToken = context.getString(R.string.color_context_legends_normal) + textUIColorToken = context.getString(R.string.color_background_default) + } + } + val token = CustomViewToken( + textUITextToken = context.getString(R.string.txt_label_s), + textUIColorToken = textUIColorToken, + radiusToken = context.getString(R.string.radius_xs), + backgroundUIColorToken = colorToken, + ) + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt new file mode 100644 index 0000000..ed508c3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/PrimaryTagView.kt @@ -0,0 +1,86 @@ +package com.remax.visualnovel.widget.ui.tags + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.ui.tags.constant.TagSize +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +class PrimaryTagView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr) { + + companion object { + const val DEFAULT = 1 + const val WHITE_ON_COLOR = 2 + const val BLACK_ON_COLOR = 3 + } + + private var sizeType = TagSize.SMALL + private var tagType = DEFAULT + + init { + includeFontPadding = false + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.PrimaryTagView) + val size = typedArray.getInt(R.styleable.PrimaryTagView_tagViewSize, TagSize.SMALL) + val type = typedArray.getInt(R.styleable.PrimaryTagView_primaryTagType, DEFAULT) + typedArray.recycle() + val padding = 8.dp + setPadding(padding, 0, padding, 0) + ellipsize = TextUtils.TruncateAt.END + maxLines = 1 + setTagType(type, size) + gravity = Gravity.CENTER + } + + fun setTagType(type: Int = tagType, size: Int = sizeType) { + tagType = type + sizeType = size + var colorToken = "" + + when (sizeType) { + TagSize.SMALL -> { + height = 24.dp + } + + TagSize.LARGE -> { + height = 32.dp + } + } + + when (type) { + DEFAULT -> { + colorToken = context.getString(R.string.color_surface_element_normal) + } + + WHITE_ON_COLOR -> { + colorToken = context.getString(R.string.color_surface_element_light_normal) + } + + BLACK_ON_COLOR -> { + colorToken = context.getString(R.string.color_surface_element_dark_normal) + } + } + val token = CustomViewToken( + textUITextToken = context.getString(if (sizeType == TagSize.SMALL) R.string.txt_label_s else R.string.txt_label_m), + textUIColorToken = context.getString(R.string.color_txt_primary_normal), + radiusToken = context.getString(R.string.radius_xs), + backgroundUIColorToken = colorToken, + ) + + changeBackground(token) + changeTextColor(token) + changeTextFont(token) + } + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt new file mode 100644 index 0000000..57e2211 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/tags/constant/TagSize.kt @@ -0,0 +1,11 @@ +package com.remax.visualnovel.widget.ui.tags.constant + +/** + * Created by HJW on 2023/7/27 + */ +class TagSize { + companion object { + const val LARGE = 1 + const val SMALL = 2 + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt new file mode 100644 index 0000000..c97c818 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/CustomViewTokenExt.kt @@ -0,0 +1,493 @@ +package com.remax.visualnovel.widget.uitoken + +import android.content.res.ColorStateList +import android.graphics.LinearGradient +import android.graphics.Outline +import android.graphics.Shader +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.EditText +import android.widget.TextView +import androidx.annotation.StringRes +import com.remax.visualnovel.R +import com.remax.visualnovel.app.base.app.CommonApplicationProxy +import com.remax.visualnovel.extension.getIconFontType +import com.remax.visualnovel.extension.getTextFontTypeface +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.bean.UIToken +import com.remax.visualnovel.widget.uitoken.view.UITokenEditView +import com.remax.visualnovel.widget.uitoken.view.UITokenTextView +import com.remax.visualnovel.widget.uitoken.view.UIView +import timber.log.Timber + +/** + * Created by HJW on 2022/9/1 + */ + +/** + * 修改文字字体 + */ +fun TextView.changeTextFont(customViewToken: CustomViewToken) { + if (customViewToken.onlyIconFont) { + typeface = context.getIconFontType() + } else { + setTokenFont(customViewToken.textUITextToken) + } +} + +/** + * 修改文字字体和颜色 + */ +fun TextView.changeTextStyle(block: CustomViewToken.() -> Unit) { + changeTextFont(block) + changeTextColor(block) +} + +/** + * 修改文字字体 + */ +fun TextView.changeTextFont(block: CustomViewToken.() -> Unit) { + when (this) { + is UITokenTextView -> { + changeTextFont(getUITokenView().apply(block)) + } + + is UITokenEditView -> { + changeTextFont(getUITokenView().apply(block)) + } + + else -> { + val uiToken = CustomViewToken() + changeTextFont(uiToken.apply(block)) + } + } +} + +/** + * 修改文字颜色 + */ +fun TextView.changeTextColor(block: CustomViewToken.() -> Unit) { + when (this) { + is UITokenTextView -> { + changeTextColor(getUITokenView().apply(block)) + } + + is UITokenEditView -> { + changeTextColor(getUITokenView().apply(block)) + } + + else -> { + val uiToken = CustomViewToken() + changeTextColor(uiToken.apply(block)) + } + } +} + +/** + * 修改文字颜色 + */ +fun TextView.changeTextColor(@StringRes textUIColorToken: Int) { + changeTextColor { + this.textUIColorToken = context.getString(textUIColorToken) + } +} + +/** + * 修改文字颜色 + */ +fun TextView.changeTextColor(customViewToken: CustomViewToken) { + customViewToken.run { + setTokenColor( + textUIColorToken, + textUIPressedColorToken, + textUIHoveredColorToken, + textUIDisabledColorToken, + textUIHintColorToken, + textUILinkColorToken + ) + } +} + +/** + * 修改阴影 + */ +fun View.changeOutline(customViewToken: CustomViewToken) { + if (customViewToken.outlineToken.isEmpty()) return + customViewToken.outlineToken.handleUIToken(context)?.outLine?.let { out -> + val radius = customViewToken.radiusToken.handleUIToken(context)?.size ?: 0f + clipToOutline = false + elevation = out.elevation + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.alpha = out.alpha + outline.setRoundRect(0, 0, view.width, view.height, radius) + if (out.offsetX != 0 || out.offsetY != 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + outlineSpotShadowColor = out.color + } + outline.offset(out.offsetX, out.offsetY) + } + } + } + } +} + +/** + * 重新覆盖圆角时,block分别设置4个角的,因为4个角已经被上一次的总radius赋值了,不会走ifEmpty了 + * 修改背景色 + */ +fun View.changeBackground(block: CustomViewToken.() -> Unit) { + when (this) { + is UIView -> { + changeBackground(this.getUITokenView().apply(block)) + } + + else -> { + val uiToken = CustomViewToken() + changeBackground(uiToken.apply(block)) + } + } +} + +/** + * 修改背景色&圆角 + */ +fun View.changeBackground( + @StringRes backgroundUIColorToken: Int, + @StringRes radiusToken: Int = 0, + @StringRes topLeftRadiusToken: Int = radiusToken, + @StringRes topRightRadiusToken: Int = radiusToken, + @StringRes bottomRightRadiusToken: Int = radiusToken, + @StringRes bottomLeftRadiusToken: Int = radiusToken +) { + changeBackground( + CustomViewToken( + backgroundUIColorToken = context.getString(backgroundUIColorToken), + topLeftRadiusToken = if (topLeftRadiusToken == 0) "" else context.getString(topLeftRadiusToken), + topRightRadiusToken = if (topRightRadiusToken == 0) "" else context.getString(topRightRadiusToken), + bottomRightRadiusToken = if (bottomRightRadiusToken == 0) "" else context.getString(bottomRightRadiusToken), + bottomLeftRadiusToken = if (bottomLeftRadiusToken == 0) "" else context.getString(bottomLeftRadiusToken), + ) + ) +} + +/** + * 修改背景色 + */ +fun View.changeBackground(customViewToken: CustomViewToken) { + customViewToken.run { + setTokenBgColor( + normalToken = backgroundUIColorToken, + pressedToken = backgroundUIPressedColorToken, + hoveredToken = backgroundUIHoveredColorToken, + disabledToken = backgroundUIDisabledColorToken, + radiusToken = radiusToken, + topLeftRadiusToken = topLeftRadiusToken.ifEmpty { radiusToken }, + topRightRadiusToken = topRightRadiusToken.ifEmpty { radiusToken }, + bottomRightRadiusToken = bottomRightRadiusToken.ifEmpty { radiusToken }, + bottomLeftRadiusToken = bottomLeftRadiusToken.ifEmpty { radiusToken }, + strokeColorWidthToken = strokeUIWidthToken, + normalStrokeColorToken = strokeUIColorToken, + pressedStrokeColorToken = strokeUIPressedColorToken, + hoveredStrokeColorToken = strokeUIHoveredColorToken, + disabledStrokeColorToken = strokeUIDisabledColorToken, + dashWidth = strokeDashWidth, + dashGap = strokeDashGap, + ) + } +} + +/** + * 设置文字大小 + */ +private fun TextView.setTokenFont(textToken: String) { + if (textToken.isEmpty()) return + textToken.handleUIToken(this.context)?.textFont?.run { + Timber.d("${this@setTokenFont} setTokenFont : $this") + val myTypeface = context.getTextFontTypeface(typeFace) + //API 28才能直接设置字重和斜体 +// val tp = Typeface.create(myTypeface,100,true) val tp = Typeface.create(myTypeface,100,true) + typeface = myTypeface + if (textFontSize != 0f) { + setTextSize(TypedValue.COMPLEX_UNIT_PX, textFontSize) + } + if (textLineSpace != 0f) { + setLineSpacing(textLineSpace, 1f) + } + } +} + +/** + * textView 设置各种文字颜色 + */ +private fun TextView.setTokenColor( + normalToken: String, + pressedToken: String = "", + hoveredToken: String = "", + disabledToken: String = "", + hintToken: String = "", + linkToken: String = "", +) { + if (normalToken.isEmpty()) return + normalToken.handleUIToken(this.context)?.run { + when (type) { + UIToken.FILL_COLOR -> { + paint.shader = null + setTextColor(this.color) + } + + UIToken.LINEAR_GRADIENT -> { + val getText = text.toString() + val x0 = 0f + var x1 = 0f + val y0 = 0f + var y1 = 0f + when (deg) { + //从左到右 + resources.getString(R.string.glo_deg_ltr) -> { + x1 = paint.measureText(getText) + } + //从上到下 + resources.getString(R.string.glo_deg_ttb) -> { + y1 = textSize + } + //从左上到右下 + resources.getString(R.string.glo_deg_lttrb) -> { + x1 = paint.measureText(getText) + y1 = textSize + } + } + val gradient = LinearGradient( + x0, + y0, + x1, + y1, + colors, + null, + Shader.TileMode.CLAMP + ) + paint.shader = gradient + } + } + } + //设置hint颜色 + hintToken.handleUIToken(this.context)?.run { + if (this.color != 0) { + setHintTextColor(this.color) + } + } + //设置link颜色 + linkToken.handleUIToken(this.context)?.run { + if (this.color != 0) { + setLinkTextColor(this.color) + } + } + if (disabledToken.isNotEmpty() || hoveredToken.isNotEmpty() || pressedToken.isNotEmpty()) { + val disabledColor = disabledToken.handleUIToken(this.context)?.color ?: 0 + val hoveredColor = hoveredToken.handleUIToken(this.context)?.color ?: 0 + val pressColor = pressedToken.handleUIToken(this.context)?.color ?: 0 + val colorList = createColorStateList(disabledColor, hoveredColor, pressColor, currentTextColor) + setTextColor(colorList) + } +} + +private fun createColorStateList(disabled: Int, hovered: Int, pressed: Int, normal: Int): ColorStateList { + val colors = intArrayOf( + if (pressed != 0) pressed else normal, + if (hovered != 0) hovered else normal, + normal, + if (disabled != 0) disabled else normal + ) + val states = arrayOfNulls(4) + states[0] = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + states[1] = intArrayOf(android.R.attr.state_hovered, android.R.attr.state_enabled) + states[2] = intArrayOf(android.R.attr.state_enabled) + states[3] = intArrayOf() + return ColorStateList(states, colors) +} + + +/** + * 设置背景色 + */ +private fun View.setTokenBgColor( + normalToken: String, + pressedToken: String = "", + hoveredToken: String = "", + disabledToken: String = "", + radiusToken: String = "", + topLeftRadiusToken: String = radiusToken, + topRightRadiusToken: String = radiusToken, + bottomRightRadiusToken: String = radiusToken, + bottomLeftRadiusToken: String = radiusToken, + strokeColorWidthToken: String = "", + normalStrokeColorToken: String = "", + pressedStrokeColorToken: String = "", + hoveredStrokeColorToken: String = "", + disabledStrokeColorToken: String = "", + dashWidth: Float = 0f, + dashGap: Float = 0f, +) { + if (normalToken.isEmpty()) return + val resDrawable = StateListDrawable() + if (pressedToken.isNotEmpty()) { + getGradientDrawable( + pressedToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + pressedStrokeColorToken, + dashWidth, + dashGap + ).let { pressedDrawable -> + resDrawable.addState( + intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled), + pressedDrawable + ) + } + } + + if (hoveredToken.isNotEmpty()) { + getGradientDrawable( + hoveredToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + hoveredStrokeColorToken, + dashWidth, + dashGap + ).let { hoveredDrawable -> + if (this is EditText) { + resDrawable.addState( + intArrayOf(android.R.attr.state_focused, android.R.attr.state_enabled), + hoveredDrawable + ) + } + resDrawable.addState( + intArrayOf(android.R.attr.state_hovered, android.R.attr.state_enabled), + hoveredDrawable + ) + } + } + + val normalDrawable = getGradientDrawable( + normalToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + normalStrokeColorToken, + dashWidth, + dashGap + ) + resDrawable.addState(intArrayOf(android.R.attr.state_enabled), normalDrawable) + + if (disabledToken.isNotEmpty()) { + val disabledDrawable = getGradientDrawable( + disabledToken, + topLeftRadiusToken, + topRightRadiusToken, + bottomRightRadiusToken, + bottomLeftRadiusToken, + strokeColorWidthToken, + disabledStrokeColorToken, + dashWidth, + dashGap + ) + resDrawable.addState(intArrayOf(), disabledDrawable) + } else { + resDrawable.addState(intArrayOf(), normalDrawable) + } + + + background = resDrawable +} + +fun getGradientDrawableOrientation(deg: String?): GradientDrawable.Orientation { + val resources = CommonApplicationProxy.application.resources + return when (deg) { + //从左到右 + resources.getString(R.string.glo_deg_ltr) -> { + GradientDrawable.Orientation.LEFT_RIGHT + } + //从上到下 + resources.getString(R.string.glo_deg_ttb) -> { + GradientDrawable.Orientation.TOP_BOTTOM + } + //从左上到右下 + resources.getString(R.string.glo_deg_lttrb) -> { + GradientDrawable.Orientation.TL_BR + } + + else -> { + GradientDrawable.Orientation.LEFT_RIGHT + } + } +} + +/** + * 获取带颜色的Drawable + */ +fun View.getGradientDrawable( + bgColorToken: String, + topLeftRadiusToken: String = "", + topRightRadiusToken: String = "", + bottomRightRadiusToken: String = "", + bottomLeftRadiusToken: String = "", + strokeWidthToken: String = "", + strokeColorToken: String = "", + dashWidth: Float = 0f, // 虚线的长度 + dashGap: Float = 0f, // 虚线的间距,0为实线 +): Drawable { + val gradientDrawable = GradientDrawable() + gradientDrawable.shape = GradientDrawable.RECTANGLE + bgColorToken.handleUIToken(this.context)?.run { + when (type) { + UIToken.FILL_COLOR -> { + gradientDrawable.setColor(color) + } + + UIToken.LINEAR_GRADIENT -> { + gradientDrawable.orientation = getGradientDrawableOrientation(deg) + gradientDrawable.colors = colors + } + } + } + val swToken = strokeWidthToken.handleUIToken(this.context) + if (swToken != null) { + if (strokeColorToken.isNotEmpty()) { + strokeColorToken.handleUIToken(this.context)?.let { scToken -> + gradientDrawable.setStroke(swToken.size.toInt(), scToken.color, dashWidth, dashGap) + } + } + } else { + gradientDrawable.setStroke(0, 0) + } + + val topLeftRadius = topLeftRadiusToken.handleUIToken(this.context)?.size ?: 0f + val topRightRadius = topRightRadiusToken.handleUIToken(this.context)?.size ?: 0f + val bottomRightRadius = bottomRightRadiusToken.handleUIToken(this.context)?.size ?: 0f + val bottomLeftRadius = bottomLeftRadiusToken.handleUIToken(this.context)?.size ?: 0f + //左上、右上、右下、左下的圆角半径 + gradientDrawable.cornerRadii = floatArrayOf( + topLeftRadius, + topLeftRadius, + topRightRadius, + topRightRadius, + bottomRightRadius, + bottomRightRadius, + bottomLeftRadius, + bottomLeftRadius + ) + return gradientDrawable +} diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt new file mode 100644 index 0000000..dbd51b0 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/UITokenExt.kt @@ -0,0 +1,234 @@ +package com.remax.visualnovel.widget.uitoken + +import android.content.Context +import android.graphics.Color +import android.os.Build +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.remax.visualnovel.BuildConfig +import com.remax.visualnovel.R +import com.remax.visualnovel.extension.readJsonAsset +import com.remax.visualnovel.utils.spannablex.utils.dp +import com.remax.visualnovel.widget.uitoken.bean.UIToken +import org.json.JSONObject + +/** + * Created by HJW on 2022/8/31 + */ +private var jsonObject: JSONObject? = null +private const val packageName = BuildConfig.APPLICATION_ID +private val tokenMap = mutableMapOf() + +fun Context.handleUIToken(@StringRes res: Int): UIToken? { + return if (res == 0) null else getString(res).handleUIToken(this) +} + +fun String.handleUIToken(context: Context): UIToken? { + if (this.isEmpty()) return null + if (tokenMap.contains(this)) { + return tokenMap[this] + } + //本地token 透明色 + when (this) { + context.getString(R.string.color_transparent) -> { + val uiToken = UIToken(UIToken.FILL_COLOR, context.getColor(R.color.transparent)) + tokenMap[this] = uiToken + return uiToken + } + } + if (jsonObject == null) { + val jsonStr = context.readJsonAsset("uitoken/token_sys.json") + jsonObject = JSONObject(jsonStr) + } + // token是正确的key + if (jsonObject?.has(this) == true) { + val value = jsonObject?.getString(this) ?: "" + when { + //文字组合 $glo.font.family.sys,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48 + value.startsWith("\$glo.font") -> { + val txtTokens = value.split(",") + // [$glo.font.family.sys,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48] + val uiToken = UIToken(UIToken.FONT) + val uiFont = UIToken.TextFont() + var fontTypeFace = "" + txtTokens.forEachIndexed { index, s -> + val gloToken = s.replace("$", "").replace(".", "_").trim() + when (index) { + //字体 + 0 -> { + val stringId = context.resources.getIdentifier(gloToken, "string", packageName) + fontTypeFace += "${context.getString(stringId)}-" + } + //字号 + 1 -> { + val dimenId = context.resources.getIdentifier(gloToken, "dimen", packageName) + uiFont.textFontSize = context.resources.getDimension(dimenId) + } + //字重 + 2 -> { + val stringId = context.resources.getIdentifier(gloToken, "string", packageName) + fontTypeFace += context.getString(stringId) + } + //行间距 + 3 -> { + val dimenId = context.resources.getIdentifier(gloToken, "dimen", packageName) + uiFont.textLineSpace = context.resources.getDimension(dimenId) + } + } + } + uiFont.typeFace = "family/$fontTypeFace.ttf" + uiToken.textFont = uiFont + tokenMap[this] = uiToken + return uiToken + } + + //颜色 $glo.color.violet.40 + value.startsWith("\$glo.color") -> { + val uiToken = UIToken(UIToken.FILL_COLOR, value.translateColor(context)) + tokenMap[this] = uiToken + return uiToken + } + + // 渐变色 @GRA:$glo.deg.ltr,$glo.color.blue.40,$glo.color.violet.40 + value.startsWith("@GRA:") -> { + // [$glo.deg.ltr,$glo.color.blue.40&$glo.transparent.t0,$glo.color.violet.40] + val values = value.replace("@GRA:", "").split(",") + val uiToken = UIToken(UIToken.LINEAR_GRADIENT) + val colors = arrayListOf() + values.forEachIndexed { index, s -> + val idName = s.replace("$", "").replace(".", "_").trim() + when (index) { + //下标0是方向 + 0 -> { + val degId = context.resources.getIdentifier(idName, "string", packageName) + uiToken.deg = context.resources.getString(degId) + } + //其他都是颜色 + else -> { + //$glo.color.blue.40&$glo.transparent.t0 + val color = s.replace("&", ",").translateColor(context) + colors.add(color) + } + } + } + uiToken.colors = colors.toIntArray() + tokenMap[this] = uiToken + return uiToken + } + + // 引用其他颜色token + value.startsWith("\$color") -> { + return value.replace("$", "").handleUIToken(context) + } + + // 大小 圆角 $glo.radius.16 + value.startsWith("\$glo.radius") -> { + val uiToken = UIToken(UIToken.SIZE, size = value.translateDimenPixel(context)) + tokenMap[this] = uiToken + return uiToken + } + + // 大小 描边 $glo.border.2 + value.startsWith("\$glo.border") -> { + val uiToken = UIToken(UIToken.SIZE, size = value.translateDimenPixel(context)) + tokenMap[this] = uiToken + return uiToken + } + + //阴影 @SHA:$glo.color.black,$glo.transparent.t15,0&4,4 + value.startsWith("@SHA:") -> { + val outLine = UIToken.OutLine() + //[$glo.color.black,$glo.transparent.t15,0&16,16] + val valueSplit = value.replace("@SHA:", "").split(",") + valueSplit.forEachIndexed { index, s -> + when (index) { + //阴影颜色 + 0 -> { + outLine.color = s.translateColor(context) + } + //阴影透明度 + 1 -> { + val alphaRes = s.replace("$", "").replace(".", "_") + val float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val alphaId = context.resources.getIdentifier(alphaRes, "dimen", packageName) + context.resources.getFloat(alphaId) + } else { + val alphaId = context.resources.getIdentifier(alphaRes, "fraction", packageName) + context.resources.getFraction(alphaId, 1, 1) + } + outLine.alpha = float * 2 + } + //偏移 + 2 -> { + val offSetSplit = s.split("&") + outLine.offsetX = offSetSplit[0].toInt().dp + outLine.offsetY = offSetSplit[1].toInt().dp + } + //elevation + 3 -> { + outLine.elevation = s.toInt().dp.toFloat() + } + } + } + val uiToken = UIToken(UIToken.OUTLINE, outLine = outLine) + tokenMap[this] = uiToken + return uiToken + } + } + } + + return null +} + +/** + * 大小token返回px + */ +private fun String.translateDimenPixel(context: Context): Float { + val idName = this.replace("$", "").replace(".", "_").trim() + val dimenId = context.resources.getIdentifier(idName, "dimen", packageName) + return context.resources.getDimension(dimenId) +} + +/** + * 颜色token返回16进制 + */ +private fun String.translateColor(context: Context): Int { + val values = this.replace("$", "").replace(".", "_").split(",") + var colorInt = 0 + var alpha = 255 + values.forEachIndexed { index, s -> + val value = s.trim() + when (index) { + // 0 是颜色 + 0 -> { + val colorId = context.resources.getIdentifier(value, "color", packageName) + colorInt = ContextCompat.getColor(context, colorId) + } + // 1 是透明度 + 1 -> { + val float = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val alphaId = context.resources.getIdentifier(value, "dimen", packageName) + context.resources.getFloat(alphaId) + } else { + val alphaId = context.resources.getIdentifier(value, "fraction", packageName) + context.resources.getFraction(alphaId, 1, 1) + } + alpha = (float * alpha).toInt() + } + } + } + val red: Int = colorInt and 0x00ff0000 shr 16 + val green: Int = colorInt and 0x0000ff00 shr 8 + val blue: Int = colorInt and 0x000000ff + return Color.argb(alpha, red, green, blue) +} + + +//将10进制颜色(int)值转换成16进制(String) +fun Int.intToString(): String { + var hexString = Integer.toHexString(this) + if (hexString.length == 1) { + hexString = "0$hexString" + } + return hexString +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt new file mode 100644 index 0000000..06b2722 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/CustomViewToken.kt @@ -0,0 +1,44 @@ +package com.remax.visualnovel.widget.uitoken.bean + +/** + * Created by HJW on 2022/9/1 + */ +data class CustomViewToken( + /** + * 文字相关 只在textView控件中有用 + */ + var textUITextToken: String = "", //edittext也可用 + var textUIColorToken: String = "",//edittext也可用 + var textUIPressedColorToken: String = "", + var textUIHoveredColorToken: String = "", + var textUIDisabledColorToken: String = "", + var textUIHintColorToken: String = "",//edittext也可用 + var textUILinkColorToken: String = "",//IM消息链接颜色 + /** + * 背景相关 全view可用 + */ + var backgroundUIColorToken: String = "", + var backgroundUIPressedColorToken: String = "", + var backgroundUIHoveredColorToken: String = "", + var backgroundUIDisabledColorToken: String = "", + var radiusToken: String = "", + var topLeftRadiusToken: String = "", + var topRightRadiusToken: String = "", + var bottomLeftRadiusToken: String = "", + var bottomRightRadiusToken: String = "", + var strokeUIWidthToken: String = "", + var strokeUIColorToken: String = "", + var strokeUIPressedColorToken: String = "", + var strokeUIHoveredColorToken: String = "", + var strokeUIDisabledColorToken: String = "", + var strokeDashWidth: Float = 0f, + var strokeDashGap: Float = 0f, + // 背景阴影 + var outlineToken: String = "", + //X Y方向的点击区域扩大 + var xClickExpend: Int = 0, + var yClickExpend: Int = 0, + var onlyIconFont: Boolean = false, + var fixTextIsSelectable: Boolean = false, + var underline: Boolean = false, +) diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt new file mode 100644 index 0000000..6b170fe --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/bean/UIToken.kt @@ -0,0 +1,55 @@ +package com.remax.visualnovel.widget.uitoken.bean + +/** + * Created by HJW on 2022/8/31 + */ +class UIToken( + var type: Int, + /** + * 颜色 type == FILL_COLOR + */ + var color: Int = 0, + + /** + * 渐变色 type == LINEAR_GRADIENT + * textview设置渐变色时,其他状态不会生效 + */ + var colors: IntArray = intArrayOf(), + var deg: String = "", + /** + * 大小单位px type == SIZE 圆角和描边使用 + */ + var size: Float = 0f, + /** + * 字体 type == FONT + */ + var textFont: TextFont? = null, + /** + * 阴影 type == OUTLINE + */ + var outLine: OutLine? = null +) { + companion object { + const val FILL_COLOR = 1 //全填充颜色 + const val LINEAR_GRADIENT = 2 //线性渐变 + const val SIZE = 3 //dimen大小 + const val FONT = 4 //字体 + const val OUTLINE = 5 //阴影 + } + + data class TextFont( + var typeFace: String = "", + var textFontSize: Float = 0f, //pix + var textLineSpace: Float = 0f //pix + ) + + data class OutLine( + var color: Int = 0, + var alpha: Float = 1.0f, + var elevation: Float = 0f, + var offsetX: Int = 0, + var offsetY: Int = 0 + ) + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt new file mode 100644 index 0000000..1874a8b --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/expend/dsl/LayoutHelperFun.kt @@ -0,0 +1,135 @@ +package com.remax.visualnovel.widget.uitoken.expend.dsl + +import android.annotation.SuppressLint +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.text.InputFilter +import android.view.MotionEvent +import android.view.TouchDelegate +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.TextView +import androidx.coordinatorlayout.widget.ViewGroupUtils +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import com.remax.visualnovel.utils.spannablex.utils.dp + +fun DialogFragment.fullScreenMode() { + dialog?.window?.apply { + attributes?.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + decorView.setPadding(0, 0, 0, 0) + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } +} + +inline var TextView.maxLength: Int + get() { + return 1 + } + set(value) { + /** + * 需要把已有的fitters规则一起添加上,否则就只剩下长度Filter了 + */ + val list = mutableListOf().apply { + filters.forEach { + if(it !is InputFilter.LengthFilter){ + add(it) + } + } + add(InputFilter.LengthFilter(value)) + } + filters = list.toTypedArray() + } + +@SuppressLint("RestrictedApi") +fun View.expandRound(left: Int = 0, top: Int = 0, right: Int = 0, bottom: Int = 0) { + class MultiTouchDelegate(bound: Rect? = null, delegateView: View) : TouchDelegate(bound, delegateView) { + val delegateViewMap = mutableMapOf() + private var delegateView: View? = null +// var delegateParentViewMap = mutableMapOf>() + + override fun onTouchEvent(event: MotionEvent): Boolean { + val x = event.x.toInt() + val y = event.y.toInt() + var handled = false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + delegateView = findDelegateViewUnder(x, y) + } + + MotionEvent.ACTION_CANCEL -> { + delegateView = null + } + } + delegateView?.let { + event.setLocation(it.width / 2f, it.height / 2f) + if (it.measuredHeight != 0 && it.measuredWidth != 0 && it.isVisible && it.hasOnClickListeners()) { + handled = it.dispatchTouchEvent(event) + } + } + return handled + } + + private fun findDelegateViewUnder(x: Int, y: Int): View? { + delegateViewMap.forEach { entry -> if (entry.value.contains(x, y)) return entry.key } + return null + } + } + + val delegateParentViewValue = ArrayList() + + fun addTouchDelegate(view: View) { + post { + val parentView = view.parent as? ViewGroup + parentView ?: return@post + delegateParentViewValue.add(parentView) + if (parentView.touchDelegate == null) parentView.touchDelegate = MultiTouchDelegate(delegateView = this).apply { +// delegateParentViewMap[this@expandRound] = delegateParentViewValue + } + val rect = Rect() + ViewGroupUtils.getDescendantRect(parentView, this, rect) + rect.left += -left + rect.top += -top + rect.right -= -right + rect.bottom -= -bottom + (parentView.touchDelegate as? MultiTouchDelegate)?.delegateViewMap?.put(this, rect) + + val viewRect = Rect() + parentView.getDrawingRect(viewRect) + //不在父布局内,就再扩一层出去 + if ( + !viewRect.contains(rect.left, rect.top) || + !viewRect.contains(rect.left, rect.bottom) || + !viewRect.contains(rect.right, rect.top) || + !viewRect.contains(rect.right, rect.bottom) + ) { + addTouchDelegate(parentView) + } + } + } + + addTouchDelegate(this) +} + +@SuppressLint("RestrictedApi") +fun View.expand(dx: Int, dy: Int) { + if (dx != 0 || dy != 0) { + expandRound(dx, dy, dx, dy) + } +} + +@SuppressLint("RestrictedApi") +fun View.expandDp(dx: Int, dy: Int) { + if (dx != 0 || dy != 0) { + val expandX = dx.dp + val expandY = dy.dp + expandRound(expandX, expandY, expandX, expandY) + } + +} + diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt new file mode 100644 index 0000000..3305754 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenConstraintLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +open class UITokenConstraintLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + ConstraintLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenConstraintLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenConstraintLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenConstraintLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenConstraintLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenConstraintLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenConstraintLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt new file mode 100644 index 0000000..78cf58c --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenEditView.kt @@ -0,0 +1,62 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatEditText +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont + +/** + * Created by HJW on 2022/8/31 + * + */ +open class UITokenEditView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle) : + AppCompatEditText(context, attrs, defStyleAttr), UIView { + + protected val customViewToken = CustomViewToken() + + init { + includeFontPadding = false + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenEditView) + customViewToken.run { + textUITextToken = typedArray.getString(R.styleable.UITokenEditView_textToken) ?: "" + textUIColorToken = typedArray.getString(R.styleable.UITokenEditView_textColorToken) ?: "" + textUIHintColorToken = typedArray.getString(R.styleable.UITokenEditView_textHintColorToken) ?: "" + textUIDisabledColorToken = typedArray.getString(R.styleable.UITokenEditView_textDisabledColorToken) ?: "" + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenEditView_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenEditView_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenEditView_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenEditView_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenEditView_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenEditView_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenEditView_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenEditView_strokeDisabledColorToken) ?: "" + } + + typedArray.recycle() + + changeTextFont(customViewToken) + changeTextColor(customViewToken) + changeBackground(customViewToken) + changeOutline(customViewToken) + } + + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt new file mode 100644 index 0000000..1e1d3b3 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenFrameLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +open class UITokenFrameLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenFrameLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenFrameLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenFrameLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenFrameLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenFrameLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenFrameLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt new file mode 100644 index 0000000..70a2253 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenImageView.kt @@ -0,0 +1,62 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + * + */ +open class UITokenImageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatImageView(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenImageView) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenImageView_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenImageView_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenImageView_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenImageView_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenImageView_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenImageView_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenImageView_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenImageView_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenImageView_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenImageView_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt new file mode 100644 index 0000000..4cde0d1 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenLinearLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +open class UITokenLinearLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + LinearLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenLinearLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenLinearLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenLinearLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenLinearLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenLinearLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenLinearLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt new file mode 100644 index 0000000..a4bbf89 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenProgressBar.kt @@ -0,0 +1,70 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.graphics.drawable.ClipDrawable +import android.graphics.drawable.LayerDrawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ProgressBar +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.getGradientDrawable + +/** + * Created by HJW on 2022/8/31 + * + */ +class UITokenProgressBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + ProgressBar(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenProgressBar) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenProgressBar_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenProgressBar_backgroundPressedColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenProgressBar_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenProgressBar_bottomRightRadiusToken) ?: "").ifEmpty { radius } + } + typedArray.recycle() + + val backDrawable = getGradientDrawable( + customViewToken.backgroundUIColorToken, + customViewToken.topLeftRadiusToken, + customViewToken.topRightRadiusToken, + customViewToken.bottomRightRadiusToken, + customViewToken.bottomLeftRadiusToken, + ) + + val gradientDrawable = getGradientDrawable( + customViewToken.backgroundUIPressedColorToken, + customViewToken.topLeftRadiusToken, + customViewToken.topRightRadiusToken, + customViewToken.bottomRightRadiusToken, + customViewToken.bottomLeftRadiusToken, + ) + + val bgClipDrawable = ClipDrawable(backDrawable, Gravity.START, ClipDrawable.HORIZONTAL) + bgClipDrawable.level = 10000 + val progressClip = ClipDrawable(gradientDrawable, Gravity.START, ClipDrawable.HORIZONTAL) + + val layerDrawable = LayerDrawable(arrayOf(bgClipDrawable, progressClip, progressClip)) + layerDrawable.setId(0, android.R.id.background) + layerDrawable.setId(1, android.R.id.secondaryProgress) + layerDrawable.setId(2, android.R.id.progress) + progressDrawable = layerDrawable + progress = progress + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt new file mode 100644 index 0000000..7bfb28e --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenRelativeLayout.kt @@ -0,0 +1,60 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.RelativeLayout +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand + +/** + * Created by HJW on 2022/8/31 + */ +class UITokenRelativeLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + RelativeLayout(context, attrs, defStyleAttr), UIView { + + val customViewToken = CustomViewToken() + + init { + /** + * 自定义控件属性 + */ + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.UITokenRelativeLayout) + customViewToken.run { + backgroundUIColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundColorToken) ?: "" + backgroundUIPressedColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundPressedColorToken) ?: "" + backgroundUIHoveredColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundHoveredColorToken) ?: "" + backgroundUIDisabledColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_backgroundDisabledColorToken) ?: "" + val radius = typedArray.getString(R.styleable.UITokenRelativeLayout_radiusToken) ?: "" + radiusToken = radius + topLeftRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_topLeftRadiusToken) ?: "").ifEmpty { radius } + topRightRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_topRightRadiusToken) ?: "").ifEmpty { radius } + bottomLeftRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_bottomLeftRadiusToken) ?: "").ifEmpty { radius } + bottomRightRadiusToken = (typedArray.getString(R.styleable.UITokenRelativeLayout_bottomRightRadiusToken) ?: "").ifEmpty { radius } + strokeUIWidthToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeWidthToken) ?: "" + strokeUIColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeColorToken) ?: "" + strokeUIPressedColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokePressedColorToken) ?: "" + strokeUIHoveredColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeHoveredColorToken) ?: "" + strokeUIDisabledColorToken = typedArray.getString(R.styleable.UITokenRelativeLayout_strokeDisabledColorToken) ?: "" + outlineToken = typedArray.getString(R.styleable.UITokenRelativeLayout_outlineToken) ?: "" + xClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_xClickExpend, 0) + yClickExpend = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_yClickExpend, 0) + strokeDashWidth = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_strokeDashWidth, 0).toFloat() + strokeDashGap = typedArray.getDimensionPixelOffset(R.styleable.UITokenRelativeLayout_strokeDashGap, 0).toFloat() + } + + typedArray.recycle() + + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt new file mode 100644 index 0000000..3e2fae7 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UITokenTextView.kt @@ -0,0 +1,152 @@ +package com.remax.visualnovel.widget.uitoken.view + +import android.content.Context +import android.graphics.Paint +import android.text.Selection +import android.text.Spannable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.appcompat.widget.AppCompatTextView +import com.remax.visualnovel.R +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken +import com.remax.visualnovel.widget.uitoken.changeBackground +import com.remax.visualnovel.widget.uitoken.changeOutline +import com.remax.visualnovel.widget.uitoken.changeTextColor +import com.remax.visualnovel.widget.uitoken.changeTextFont +import com.remax.visualnovel.widget.uitoken.expend.dsl.expand +import androidx.core.content.withStyledAttributes + +/** + * Created by HJW on 2022/8/31 + * 文字颜色暂时无法做到press enable等效果下渐变色 + * + * ImageView, Layout, Edittext + */ +open class UITokenTextView @JvmOverloads constructor(context: Context, private val attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + AppCompatTextView(context, attrs, defStyleAttr), UIView { + + protected val customViewToken = CustomViewToken() + var clickWithSelectableBlock: (() -> Unit)? = null + + init { + includeFontPadding = false + obtainStyledAttributes() + changeStyle() + } + + fun obtainStyledAttributes() { + context.withStyledAttributes(attrs, R.styleable.UITokenTextView) { + customViewToken.run { + textUITextToken = getString(R.styleable.UITokenTextView_textToken).takeIf { it != null } ?: textUITextToken + textUIColorToken = getString(R.styleable.UITokenTextView_textColorToken).takeIf { it != null } ?: textUIColorToken + textUIPressedColorToken = + getString(R.styleable.UITokenTextView_textPressedColorToken).takeIf { it != null } ?: textUIPressedColorToken + textUIHoveredColorToken = + getString(R.styleable.UITokenTextView_textHoveredColorToken).takeIf { it != null } ?: textUIHoveredColorToken + textUIDisabledColorToken = + getString(R.styleable.UITokenTextView_textDisabledColorToken).takeIf { it != null } ?: textUIDisabledColorToken + textUIHintColorToken = getString(R.styleable.UITokenTextView_textHintColorToken).takeIf { it != null } ?: textUIHintColorToken + textUILinkColorToken = getString(R.styleable.UITokenTextView_textLinkColorToken).takeIf { it != null } ?: textUILinkColorToken + backgroundUIColorToken = + getString(R.styleable.UITokenTextView_backgroundColorToken).takeIf { it != null } ?: backgroundUIColorToken + backgroundUIPressedColorToken = + getString(R.styleable.UITokenTextView_backgroundPressedColorToken).takeIf { it != null } ?: backgroundUIPressedColorToken + backgroundUIHoveredColorToken = + getString(R.styleable.UITokenTextView_backgroundHoveredColorToken).takeIf { it != null } ?: backgroundUIHoveredColorToken + backgroundUIDisabledColorToken = + getString(R.styleable.UITokenTextView_backgroundDisabledColorToken).takeIf { it != null } ?: backgroundUIDisabledColorToken + radiusToken = getString(R.styleable.UITokenTextView_radiusToken).takeIf { it != null } ?: radiusToken + topLeftRadiusToken = (getString(R.styleable.UITokenTextView_topLeftRadiusToken) ?: "").ifEmpty { radiusToken } + topRightRadiusToken = (getString(R.styleable.UITokenTextView_topRightRadiusToken) ?: "").ifEmpty { radiusToken } + bottomLeftRadiusToken = (getString(R.styleable.UITokenTextView_bottomLeftRadiusToken) ?: "").ifEmpty { radiusToken } + bottomRightRadiusToken = (getString(R.styleable.UITokenTextView_bottomRightRadiusToken) ?: "").ifEmpty { radiusToken } + strokeUIWidthToken = getString(R.styleable.UITokenTextView_strokeWidthToken).takeIf { it != null } ?: strokeUIWidthToken + strokeUIColorToken = getString(R.styleable.UITokenTextView_strokeColorToken).takeIf { it != null } ?: strokeUIColorToken + strokeUIPressedColorToken = + getString(R.styleable.UITokenTextView_strokePressedColorToken).takeIf { it != null } ?: strokeUIPressedColorToken + strokeUIHoveredColorToken = + getString(R.styleable.UITokenTextView_strokeHoveredColorToken).takeIf { it != null } ?: strokeUIHoveredColorToken + strokeUIDisabledColorToken = + getString(R.styleable.UITokenTextView_strokeDisabledColorToken).takeIf { it != null } ?: strokeUIDisabledColorToken + outlineToken = getString(R.styleable.UITokenTextView_outlineToken).takeIf { it != null } ?: outlineToken + xClickExpend = getDimensionPixelOffset(R.styleable.UITokenTextView_xClickExpend, xClickExpend) + yClickExpend = getDimensionPixelOffset(R.styleable.UITokenTextView_yClickExpend, yClickExpend) + strokeDashWidth = getDimensionPixelOffset(R.styleable.UITokenTextView_strokeDashWidth, 0).toFloat() + strokeDashGap = getDimensionPixelOffset(R.styleable.UITokenTextView_strokeDashGap, 0).toFloat() + onlyIconFont = getBoolean(R.styleable.UITokenTextView_onlyIconFont, onlyIconFont) + underline = getBoolean(R.styleable.UITokenTextView_underline, underline) + fixTextIsSelectable = getBoolean(R.styleable.UITokenTextView_fixTextIsSelectable, fixTextIsSelectable) + } + + } + } + + /** + * textView设置了isSelectable = true时 + * 解决android 8 TextView设置textIsSelectable="true" 报角标越界异常bug + */ + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (customViewToken.fixTextIsSelectable) { + val startSelection = selectionStart + val endSelection = selectionEnd + if (startSelection < 0 || endSelection < 0) { + Selection.setSelection(text as Spannable, text.length) + } else if (startSelection != endSelection) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + val text = text + setText(null) + setText(text) + } + } + } + return super.dispatchTouchEvent(event) + } + + private var lastActionDownTime = -1L + + /** + * TextView设置了长按复制、OnClickListener冲突的解决 + * @param event MotionEvent + * @return Boolean + */ + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (isTextSelectable && clickWithSelectableBlock != null) { + when (event?.action) { + MotionEvent.ACTION_UP -> { + val actionUpTime = System.currentTimeMillis() + if (actionUpTime - lastActionDownTime <= ViewConfiguration.getLongPressTimeout()) { + /** + * 这里失焦一下 + */ + clickWithSelectableBlock?.invoke() + return true + } + } + + MotionEvent.ACTION_DOWN -> { + lastActionDownTime = System.currentTimeMillis() + } + } + } + return super.onTouchEvent(event) + } + + private fun changeStyle() { + changeTextFont(customViewToken) + changeTextColor(customViewToken) + changeBackground(customViewToken) + changeOutline(customViewToken) + if (customViewToken.xClickExpend != 0 || customViewToken.yClickExpend != 0) { + expand(customViewToken.xClickExpend, customViewToken.yClickExpend) + } + if (customViewToken.underline) { + paint.flags = Paint.UNDERLINE_TEXT_FLAG + paint.isAntiAlias = true + } + } + + override fun getUITokenView(): CustomViewToken { + return customViewToken + } +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt new file mode 100644 index 0000000..b07dac8 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/uitoken/view/UIView.kt @@ -0,0 +1,12 @@ +package com.remax.visualnovel.widget.uitoken.view + +import com.remax.visualnovel.widget.uitoken.bean.CustomViewToken + +/** + * Created by HJW on 2022/9/20 + */ +interface UIView { + + fun getUITokenView(): CustomViewToken + +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/anim_keep.xml b/VisualNovel/app/src/main/res/anim/anim_keep.xml new file mode 100644 index 0000000..75d2752 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/anim_keep.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml b/VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml new file mode 100644 index 0000000..0a98dbd --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_alpha_cancel.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml b/VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml new file mode 100644 index 0000000..7ea4c68 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_alpha_show.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml b/VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml new file mode 100644 index 0000000..28a93a3 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_translate_cancel.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/dialog_translate_show.xml b/VisualNovel/app/src/main/res/anim/dialog_translate_show.xml new file mode 100644 index 0000000..a3e2717 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/dialog_translate_show.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/no_anim.xml b/VisualNovel/app/src/main/res/anim/no_anim.xml new file mode 100644 index 0000000..19107ad --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/no_anim.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_anim_in.xml b/VisualNovel/app/src/main/res/anim/picker_anim_in.xml new file mode 100644 index 0000000..4624111 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_anim_in.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_anim_up.xml b/VisualNovel/app/src/main/res/anim/picker_anim_up.xml new file mode 100644 index 0000000..ffea42c --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_anim_up.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_fade_in.xml b/VisualNovel/app/src/main/res/anim/picker_fade_in.xml new file mode 100644 index 0000000..c73f2e4 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_fade_in.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_fade_out.xml b/VisualNovel/app/src/main/res/anim/picker_fade_out.xml new file mode 100644 index 0000000..e23d4e7 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_fade_out.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml b/VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml new file mode 100644 index 0000000..385500e --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_hide2bottom.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/anim/picker_show2bottom.xml b/VisualNovel/app/src/main/res/anim/picker_show2bottom.xml new file mode 100644 index 0000000..e445754 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_show2bottom.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/VisualNovel/app/src/main/res/anim/picker_top_in.xml b/VisualNovel/app/src/main/res/anim/picker_top_in.xml new file mode 100644 index 0000000..b081293 --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_top_in.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/anim/picker_top_out.xml b/VisualNovel/app/src/main/res/anim/picker_top_out.xml new file mode 100644 index 0000000..c86790b --- /dev/null +++ b/VisualNovel/app/src/main/res/anim/picker_top_out.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml b/VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml new file mode 100644 index 0000000..a4ba867 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/fragment_actor_bg.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml b/VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml b/VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/progress_recording.xml b/VisualNovel/app/src/main/res/drawable/progress_recording.xml new file mode 100644 index 0000000..faa884a --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/progress_recording.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml new file mode 100644 index 0000000..9abd3b7 --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml new file mode 100644 index 0000000..970743a --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/shape_dialog_text_mask_2.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml b/VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml new file mode 100644 index 0000000..3cb27ef --- /dev/null +++ b/VisualNovel/app/src/main/res/drawable/tag_flow_item_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml b/VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml new file mode 100644 index 0000000..1c8cbcc --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/button_vip_with_iconfont.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/button_with_icon.xml b/VisualNovel/app/src/main/res/layout/button_with_icon.xml new file mode 100644 index 0000000..d9dab79 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/button_with_icon.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_double_btn.xml b/VisualNovel/app/src/main/res/layout/dialog_double_btn.xml new file mode 100644 index 0000000..502c57a --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_double_btn.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_loading.xml b/VisualNovel/app/src/main/res/layout/dialog_loading.xml new file mode 100644 index 0000000..9586a55 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_loading.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml new file mode 100644 index 0000000..3438c1f --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml new file mode 100644 index 0000000..c6c9109 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_single_btn_layout_2.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml b/VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml new file mode 100644 index 0000000..9080fda --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/dialog_single_btn_with_icon.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml b/VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml new file mode 100644 index 0000000..20611a1 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_image_viewer_dialog.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_actor.xml b/VisualNovel/app/src/main/res/layout/fragment_main_actor.xml new file mode 100644 index 0000000..452f402 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_actor.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_book.xml b/VisualNovel/app/src/main/res/layout/fragment_main_book.xml new file mode 100644 index 0000000..c952614 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_book.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_history.xml b/VisualNovel/app/src/main/res/layout/fragment_main_history.xml new file mode 100644 index 0000000..50d307f --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_history.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/VisualNovel/app/src/main/res/layout/fragment_main_manga.xml b/VisualNovel/app/src/main/res/layout/fragment_main_manga.xml new file mode 100644 index 0000000..4e75101 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/fragment_main_manga.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/VisualNovel/app/src/main/res/layout/item_album.xml b/VisualNovel/app/src/main/res/layout/item_album.xml new file mode 100644 index 0000000..176c2a4 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_album.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml b/VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml new file mode 100644 index 0000000..a0ced63 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_imageviewer_photo.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml b/VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml new file mode 100644 index 0000000..393a1db --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_imageviewer_subsampling.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml b/VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml new file mode 100644 index 0000000..16868a6 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_imageviewer_video.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml b/VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml new file mode 100644 index 0000000..280c655 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/item_photo_custom_layout.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/layout_empty.xml b/VisualNovel/app/src/main/res/layout/layout_empty.xml new file mode 100644 index 0000000..c78da76 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/layout_empty.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/layout_epal_crop.xml b/VisualNovel/app/src/main/res/layout/layout_epal_crop.xml new file mode 100644 index 0000000..09a44f3 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/layout_epal_crop.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/layout_toolbar.xml b/VisualNovel/app/src/main/res/layout/layout_toolbar.xml new file mode 100644 index 0000000..90ea950 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/layout_toolbar.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/load_more_loading_view.xml b/VisualNovel/app/src/main/res/layout/load_more_loading_view.xml new file mode 100644 index 0000000..3f1c017 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/load_more_loading_view.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml b/VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml new file mode 100644 index 0000000..f489527 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/pick_bottom_bar.xml @@ -0,0 +1,33 @@ + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_crop.xml b/VisualNovel/app/src/main/res/layout/picker_activity_crop.xml new file mode 100644 index 0000000..60383d1 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_crop.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml b/VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml new file mode 100644 index 0000000..dc2409e --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_crop_cover.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml b/VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml new file mode 100644 index 0000000..26e6fe9 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_fragment_wrapper.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml b/VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml new file mode 100644 index 0000000..58c6d34 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_multi_crop.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml b/VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml new file mode 100644 index 0000000..9b39335 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_multipick.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_activity_preview.xml b/VisualNovel/app/src/main/res/layout/picker_activity_preview.xml new file mode 100644 index 0000000..e37e2d6 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_activity_preview.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_folder_item.xml b/VisualNovel/app/src/main/res/layout/picker_folder_item.xml new file mode 100644 index 0000000..55b79d7 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_folder_item.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml b/VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml new file mode 100644 index 0000000..74c74d2 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_image_grid_item.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/picker_item_camera.xml b/VisualNovel/app/src/main/res/layout/picker_item_camera.xml new file mode 100644 index 0000000..ed75b92 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_item_camera.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_item_root.xml b/VisualNovel/app/src/main/res/layout/picker_item_root.xml new file mode 100644 index 0000000..6a4e051 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_item_root.xml @@ -0,0 +1,5 @@ + + diff --git a/VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml b/VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml new file mode 100644 index 0000000..78f35c9 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_redbook_titlebar.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml b/VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml new file mode 100644 index 0000000..69bd70c --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/picker_wx_preview_bottombar.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml new file mode 100644 index 0000000..dcb8177 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_btn_tips.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml new file mode 100644 index 0000000..717cca7 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_feeds_back_tips.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml new file mode 100644 index 0000000..937bbf3 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_switch_tips.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/popwindow_tips.xml b/VisualNovel/app/src/main/res/layout/popwindow_tips.xml new file mode 100644 index 0000000..6af1d8c --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/popwindow_tips.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/view_load_more_common.xml b/VisualNovel/app/src/main/res/layout/view_load_more_common.xml new file mode 100644 index 0000000..db4ebca --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/view_load_more_common.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/widget_item_like.xml b/VisualNovel/app/src/main/res/layout/widget_item_like.xml new file mode 100644 index 0000000..0b6bebf --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_item_like.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/widget_lock_tag.xml b/VisualNovel/app/src/main/res/layout/widget_lock_tag.xml new file mode 100644 index 0000000..ece0aa9 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_lock_tag.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/layout/widget_price_view.xml b/VisualNovel/app/src/main/res/layout/widget_price_view.xml new file mode 100644 index 0000000..c0f2e32 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_price_view.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/layout/widget_user_avatar.xml b/VisualNovel/app/src/main/res/layout/widget_user_avatar.xml new file mode 100644 index 0000000..15aa8e2 --- /dev/null +++ b/VisualNovel/app/src/main/res/layout/widget_user_avatar.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/VisualNovel/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_status_error.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_status_error.webp new file mode 100644 index 0000000000000000000000000000000000000000..13545c4e3aeb38bdddb49af75ceb472c04d2da2b GIT binary patch literal 4580 zcmb`JXH*p1vVePrpu_zGx|0_gf_r%K?Fe*z=KrGA`#mj(HsZgKhqU zpLx4`<8u1`JW{LHB~8;XU)Aq#i5Ys{-Z*T-v_@$q6F1!{nn`9fhnTSZ5agP#81Tvo*QX8Fxc?+knOfy1G;Ab)KV0wm zPyxUBCO*#d_|r9~^UK+%%0@`qPFh7~sYl;aU9`^5(q$dhH?eAi`J%ga#<=Yl{LG5| zXX(=Znl>l*m*2cabu`a6+|w3J`g4-mK17}s-D@NpxJLfQd$=Z}`3hVsrc=osE6+L_ zzmiq#f(lbXnxRYQgjwNU%}hm3kS2)|?!Nkj9dy|MT$#Ji7gg?OS`y)ngkzHRNX`w+ zFy4A3L~yg>(rQ6FZDdwar$j=n)RYskE1pyayk|9^@QpVj+GF&)_s@7riWD6V{%ERv^P)V)Do5evmzPd~mxjd*EtJ zlU??#%FVbw>@0&FQWkMJ>?BtJaZDJva(B%IpJhkcnDSt})h0O6_EJ!3b%%r z%oo9Kv9XYf=>B!I=Wzr zDjFjiXvZ2@eHf667U9_EjL5|1Ypl#YClc(jy@63=gO|jBjnA@6?uRPa(}9b_k9uDb z5(6uCDJgaOwCpB^(2jz#0wKamenUui1De9@l+DIixc@oVRj3n)4EGBH&YI%!5z8cIf7h;AP@lBl|s3 zl1fodRbI9#i7whNp1EeSoZY$CmxAo3kRDpig(cqfi=EXr#IOdBS*v+P$7V=b+S_FUuMQxmaA?UKOvxzm)~0ZhH?QFx{^2gGRE_yEYvaPjkt0m> zB{r}?$B8S%Hiu;;Qz*-a|9-7sEU5-2c({PDi@Q=g!g z98~-Fi)h``qBit3=5yvl7#1hHCxXANu}PQv9n6Q2 z&75B_nKlJE0CxDB5(`{mPvf^XFiTyKNh}i#h0wt5iIi_(T#gV|@$Nxi$tx`*gR1dg z5w&8{tsh^Leq7yRpvey0gZe|iN_+Vs)^#+d!2zCEP8`e0QD+mr&^4hnts zD6PJRTntnvn7D+tTFZ}Tbp^{)9`}tSN zpIqc~)dEFw?$1++a=wgZsVF3fo7?KM3f?{7Fp1E(UGhp{B`zk2Uc0criG1 z0%kswrB*v16V4xsOc|1qKYPeaNfwS*Tgvs;?$*AmzG-v!H^y!d5xC>67lIP?bNqas zAn>YUnNG<=e7Ado;6PKUIBpzYE0&tis^(U79W#Y2KK2or#E|Xc{wJ-?m8ccVwWOIULX3au}sdq^8QTqsyA1b5ksB%k<^GCtw*mEtd zVN^Gmj7UU+M^`v^4n^7zA8gZA;Ne^skcjH_EH7|vcT(BvsCUzfdc)@{V+&bqjv*nF+=F1@X5i`{WH!ST&x z{24o=2t%)15VV%aXr^y%FZ2p@gRf6yJT`^4?A=WUVfhcoad{tV6EbfT;#WJ*Odj_2 z?H`YoG!=|5$1xnRNsWH_^ybN~8&y{3#0)KPOnhwLOwaZJ^F?u6OQJK8F#-@a$I|+^%&)n6IirgMF!Ruj&6x1Jeyk4WFtil zcejKV9FTd~>~L$Z<%I1%LEKXWVHH+165q4cq)E6o4wDzUsMJnHB1*z;IPLFMG;;&n ztcvUEVykywF*NH6c2uKhU@kL8@>((x#lcyI`AGurf!!0G42Y@q-cZ>5dvnRWjkfeS z1KEeV`~`rj-$I(%n2w0P*t4(6A=#o6>BmdaprdaN^~d06vaFo98B=`$Qg=yldt45A&eLGpT~;6L3Ic8p>CP z+LqAxTwJa{apPAjqHBUy2uph|_c&v5`B=rC{{y>t8QuT@ literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_tips.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_tips.webp new file mode 100644 index 0000000000000000000000000000000000000000..db75a2c61ab8ae9d1687fe39d30f932004cdf16f GIT binary patch literal 736 zcmV<60w4WSNk&H40ssJ4MM6+kP&iD>0ssInKfn(Vhr%e5Bt_M;c=$a3QqB|e*kBY% zlA`KaJba#iDd&lK=AU8Pwvm%oWVF3EK->eEXLB5!CN7If2{Gb0OpI6-?Rz;6^u64- zxh}M=oG02=t_xu;P&HSK1cSshfM0Wrs0k=CO0^K1I5^UQ#9_hw5 zZ&~f`vfC=-oBwQ*|ND}M2}JbYf!aor6snk_n4ZZpb?y@Y00000000003Z+`JH=2^u zQLkAm6#xJL000000CG>U-X}?tBuSF&*NbMJe04~Y!*jJnLqHAp*Y$Je`+-G5%UeUZ(wLlemRM%1?~py*q1-_%3X zH{JWT|J>j{x46$u{&SoA+~_{Hy3fttb9+1IzBhR9E$(}hYHu?M*MfF$ROed7eQ)*N Sn+^B&fuO>R_jdRH4FCX=&}ruY literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_checked.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_checked.webp new file mode 100644 index 0000000000000000000000000000000000000000..9d59f45c9054fca52b9b97869fa958e7f11e1524 GIT binary patch literal 1010 zcmVriR0M1hoX#-IcFno%@|Nr0TP)5WA!1R&x`Q~SQ zx7}{Hck$2b&(0ATSG@C#G-l^sg@-dSvsp(@|G-q zV?#3{tHXw|9$DWt6cEF}#7;;o!yRqyl$a)MO}iacN){4w!m=$nINcCDQ#p0DAi8**Fkt2S=Awf@{O(YousIDYgRRE7f z^?(am&VgK(496twk&;IF()c&h-29T}S0YWalx9i)eV5WK6KRrP()_xW=Ej%CSJJ3s zX^xP~k^vX8oC6(MJfOOkWK{t$kz@=2N)qQ85;+pQIFe)d5@IP9c-56cy@t6AGZd`L zP%nbF61+w6h+l+~Eq={1=yB_z{28wRIwMXYwC-kn(j7fG$+)C#=-!QZgu5_!#vy>& zzI{EIZhf^YYQn;IHob+NjkzuJI;?eL8)6q$j}lYK5Z0^7THku&fMvCFk?Y7WI#n<} zcE8;IPVb-Hr@wD~bqv#1P&gnC0ssK;4FH`1Do_AW06w)!oJuDoBB8PLn&{vbiE04E zUJ5{;=$w@NnI!acFdNf*%m<(Upbg*-d@o{O+{BBa0M*9x@6WJ)uS(~5eE|`j#cEgz z{3x(XX4z=49YY>yqqcrN^+lSA07;0~kEH8@S4sMo8jClwbc6r^{`JZK{HA~TIDh|6 ziroK+fpl(dfAn^4L=K(stkEFBtEzC74?w1Da0YqK_hAY95g(^GSIOC*=N9%u%eZ+v z#$E* z_*DyB7f7ZaoCv>8j*tmN0^_(qXeqy@ocQGUJv|ZqgGpJT1MwcY`*-8%2k^-f4iz6= z{(f!6)g{vyxI?2SQTU@$VBDIZ{vO*lj?tqRz;)BB@0Umr#>%Uz2v$7qhxYDzhwY{0 z-M^ohjHKh_6QIMSeFw_bzPyMBX({wprB(5hc0BY)=quHY+t87`L7O zR;U1)XT(0KZUcv6{&Zm%oH2_;2MbbPeH1$cJmnvDK+l0A2KbF_z-67O8OH00lByI^ zr=ug_hSF1f)yYK~S8=`+xN|!xdzWF gK@qQ<`Hgy&4f0QODe5&!@I literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_diamond.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_diamond.webp new file mode 100644 index 0000000000000000000000000000000000000000..40f56f38f1d00498e6a21e957544bcbbf0d1cdb3 GIT binary patch literal 2554 zcmV<|BMM6+kP&il$0000G0001g004gg06|PpNDl=700E#zYnvfS z`XbBCv2EM7ZQHhO+qP|6J+^H&%E*jZW@cr^xA+$k69E5@T1*5rbLkCR2UW}%Zon$> zoX>%PJNjMJk`6@^nGs67!OQ-VQpx@4=Wh84V??<$5mqC?KuEjY z$JC_m_ZOD7E}SZX%|JMp>1s-V2V&9;aB!k5({!5?)kx4eQM@PXc2fs z@?Mdc2C3iq`TqXt?Mo!H5YCW7_avrS(G&aW=GFmK(uW!OA(VS6F>O=P6Z_`E%FYE7 zn7AO6drFx;sq4+L@pUtY2}EC}@=utasqgEhg`G=fjBhg$otUb;V!EdR`^)3AS`|ta z-)hjFrTII?Nzw7<;<{l?i=>M*X)3huH^x)R7ybO~$^lifnl%$PCWdjB1{@#nbuwur zY>|*?WS4snkmUtP*y^F$wmSmluas63w5~S2y0*vqO zso}v%w@+l8rw{UyAdoC){fzsb5jOQmu8V$#;N7&Uk!%+vhM}*UK_wyEWy)}jN}y7< z>y#nsZKh7Lqm-d}E<%kYOZ*H^PXnTeq}%9Xn4Sn%LUNt)GF0O#Dj)@~1sJa4Hk-Ue zJ1E2UODTCN&*0-*YLjIf=|+b>%17jo0*@&+%sJL32SUa>(VMN|SV_^b*CMhY;}>+;p%8gws)$Zp1$mJ62|9F> zNuH^l2OIVympn*z79Bd(i%dX0aR2jY%Q0_eAaM}Mxli9pX0 z$8`6zdkE-SC=;zO>3s_`=sa>&Ku^A?vPRH5De*=}&TVN4=s#kTaZ%Lu&8{DSJhu{gN^+!Tg+wfCi?4S1UPzSiioq6GNbz^ARi-JHN% z>prR_`|k9>--182LqCj21RORlm9*lIsv8Pi&iYm(_H|iIz-Q~ofF|zM3?;zpjCVC4 zZcGFmH}`Y~%{|)+T+eVp3X*4CJm9;r?T_HNcBUnqgTw~|!LYB(BmnP)>OX@JJ1wOE z{14qKsut5Lf&hkjeyR1Uk_m7WW>Z!CCo%z=so$$Wqh}`uM2GqniSPPG0;WlCDe>QK z915r!+B)QkM+=w%*`z0^9M{q`0$^*b`&Gv0_Mw1qyoLV1qEE^h0Ao_%F8z~uqlf@F zTgNyVWB%(!1;9GjBg(v|%NPM|^U!Gj-dsjNJYky=IRrr6WEFs;09H^qAiM+s0FWO5 zodGI<0Du5KwM?K&CnO^wt&^Q#;1-EM8FQ6#bThGxh7H5q@EUEK$N#z5FvbUW-n{@4 zyIFS#a$th}+6JH6+Nf&6UjG|WJ+aore}IAp>8qMprhMm*o$U*(+*W%DHv2RXT}9e0 zCF>cBZl`1XvA%T?yw+9}W5I%zt~ge|^5C`cOWS2*saxQSzDfXXPmT%?B9$lpS|%{} zUO9D6zI!ek8+98&a5|8+8$g;^d*GD$bRfM8Xy!nYnIoah!L!z&l6`=Z~y@Q z@^l7o;&nIl6B2^Nl_2dnE0Xph%e;RFDIsr7rOW+lyv8uLQcmTW__fvK)6gN_*kKMC zl~iEpaf=KQ=6whLOr_*MJkYI3a&KoFAP!HjqN&o;Rp? z(+^njdW}{gcRx}p|Hmelk5bS6;Z->b1HW-s$!Ly!%#mE^tVjR%zlXp7-vFLHupWO< zr@s>`F6*-@XOEC69@@x8f1Wyr`F!LqMpvl$jwQM;sM!!eD1j3H{27^KC8T3E5M;(6*&Qedf5M$Mc&zTb0`~Z`*?)3; z^K;AY_t^2f<2uluLb#}Yjk69S%cftH6cLQF^ z`b5mrAsz#MHx!D9Bj6soKs>~sY&{5dRHpQ}jq9vFA?9z6vUkEj1GORH`97A`Ud{lXK6`G1VOPJCiD*N=}rQk&6Z}Xfwx8+v-KBY;D@8| z$NXtkeG5Whr0lWRML*A3IYrnjsVB>vChJX3oc)QigV5*YmIjpCRn&*{!oExvx+AsBZ{~Z2k_GCSvM*pw|wFEPb z3u&U{X#NlT?IoM;z(zx205C0{h2PnVmw;U)e~tS4Qxz@~l$LubgBHf{q6$v&7JsW- zHGf@M=~B}b7y1N^MfNu~OX2^{a#GA*S1kq>Y;XIZ!_`ax(N6;5q((VbB9jXBrX@pp zOD(9Cfg!%o>c%GGiv|<06b{jJ^%m! literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/icon_multi_checked.webp new file mode 100644 index 0000000000000000000000000000000000000000..7a9d67186f9f678d5cfeebd423819f1070eb044a GIT binary patch literal 492 zcmWIYbaQ*c$iNWp>J$(bVBvEE$kw+$z$XyDP-D`Pv=fQn`&Iw^ZrpruN3OKztmvP< zJEQ;T*!<5@^6WZXTfRq;??j;k!}sd{BJVku*dNUQr`aJdsOjf)sWZZ*!QI%=b#teO z!<~#Yrv_doo<$iAlOjSQ&bhX_CIuGiiHVANOj7ZjxI`u`?Ly+Y|Mmq-Hf?gbB)(4Y zZ~B96xpNNO+dD6BR?WgGL0+1xcdLtUX8?kN^2(|WkzSgqWm}6_4$ScHzixGBUz$l? zS=ycEkK)yDO(oy>)StP>7PoHdCEeVL#ME9Hv+~Jh1(UX1?p_jmPwlN7$P^qv!BSO! ze!gYhm8ko{HS4!tn=f$xRZ6_%zH7(kDc9uvlzMpC|L~dl{rC6J@$&qne5if>wlD2H zV$yZ?d^7v5KX=tVXW?Rh*+egMHTgl&oSwa>kNN66oBwIf+>mt_(=2}~uAtBC`)Z%2Iq7Oh^QUtV|fq^jv$QDV5 zuq#09BnZ0)#4ZX6at5kd0Ay>VBe9c^*ySY!B|tSNKIgA?khp~qQ(3^k zz~92aulnH%e}qq&AUw2 zfVHxgm*?MG$^i|*c9ThV44(zg6E_s8d+^F>Q)zL&HevhaP}*~(A9l{f72gq%au9I zcI$5YWVLt?&81U<3}@(GpQPf6KwIL@vAKF_F3q_joevbRDa$>!SL$o{q$k!9UyXTH zLrd;iY)RH^`}?Y==6#VPLLX3Vcj~^)(=XkyJCp{@wv~cHZR>N^}x4y9dE-=cEw<6>o4rR2mcvc#PJncOi!)MW7nS= z?`SbqG|uqGbhdVZH~Z{XeQS}~R4W~OEAE8*cY%1{`EKVkIo7$o&+VA6Khx@n(_M30 zpV@me&g}TyvDi7XPF8x)49QcfZ1Go_<)SM-J`vt8|6bCpdSmjN>$Rb$)%45GN|(Bw zhUdwZ5Z(@S@zeAu`(GRN0~XRT{;_Ja$zcJF&O|M!(YpLfmL b|H{-XaBth&LNh~Kzl?m*`e(1W#`;;zVDnn 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 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_video.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_icon_video.png new file mode 100644 index 0000000000000000000000000000000000000000..6ec8e45652505bb2b0467e9c95c8e578035a70f9 GIT binary patch literal 7629 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 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video.png new file mode 100644 index 0000000000000000000000000000000000000000..14c839881830df20201e2c25d404b4acc8309320 GIT binary patch literal 658 zcmV;D0&V??P)s7-r427>I05*ULXeR(RU<29>$^@_hY(P5#695y?PC!SX6NsbW^w@45ZgSG&*vj9; zvF@jbV@HI{FUnj$6o5uR<^(haG7kVPf|>%F0u}*gegLolFo_(@8o(=S@{0GH;OEdT-cNJLv=W(oP1t^^|) zHz%T{ZA(B@OVmsSfbab;05hQzi8#j$W)@G1*curmkIy?7d0!{UVQ2Rq^QYqZeNc0)B*Te zdmuh10F~l%0Z=PG2LO%YQwPv0K4kzw;!_0>EIvs9$9}mUeE5z1OHcurO;G6GMI*r9 z{Amh|d^@Z)Kx@GNNl@seMNo^NcLb=bfUW|gKNVgQ(LCECXoLqtNNyFdJb&P0CoV*yuw%2H<0YlQRx!-Y=e+&1jxAnGfQ(mW$coRobveiZbH&{L0fyl+o#T4tW5>B}mPB3!^MiZ(g(yGS}KnrNL` zROoygNrgT&%Q;VDW+5dh(Uv=*1`hR{2$xn_r#G<6WJet+c9|gdF$y4d4-IXfM!?X7 sV}HL!K*4-WGmJSpoWx&QzG07*qoM6N<$f)SbuT>t<8 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video_mask.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/picker_item_video_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..77fe43c65fb950495d6e115b08c124550f1f82bd GIT binary patch literal 23692 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 + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/attrs.xml b/VisualNovel/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..1b12daa --- /dev/null +++ b/VisualNovel/app/src/main/res/values/attrs.xml @@ -0,0 +1,1452 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/colors.xml b/VisualNovel/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fa7dd30 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/colors.xml @@ -0,0 +1,188 @@ + + + #13131A + #99120E1B + #CC120E1B + + #F6F6FF + + #33BCBEFF + #33BCBEFF + #FFFFFF + #ADADBF + + + #FF6252F9 + #B08EFF + #8957FF + + #FFFFFF + #1AFFFFFF + #26FFFFFF + #33FFFFFF + #40FFFFFF + #4DFFFFFF + #80FFFFFF + #B3FFFFFF + #00000000 + #000000 + #E6000000 + #B3000000 + #2B000000 + #A6000000 + #99000000 + #80000000 + #4D000000 + #33000000 + #26000000 + #1A000000 + #26FFFFFF + + + + + #FFECDE + #FFD7B8 + #FFBF8F + #FFA264 + #FD8239 + #F25E0F + #99F25E0F + #D04500 + #A83400 + #7B2300 + #4D1400 + #FFF8DE + #FFEFB3 + #FFE386 + #FCD258 + #F3BC2A + #E6A100 + #C78800 + #A26B00 + #784D00 + #4D2F00 + #F8FFDE + #EDFCB8 + #E0F68F + #CFED67 + #BAE041 + #A0CD1E + #82B500 + #689600 + #4B7200 + #304D00 + #DEFFE7 + #B9FCCD + #94F7B1 + #6FEE96 + #4AE27B + #28D061 + #0BB84A + #00983C + #007331 + #004D22 + #DEFFF8 + #B6FBED + #8DF3E2 + #65E9D5 + #3FDAC4 + #993FDAC4 + #1DC7B0 + #00AD96 + #009182 + #006F67 + #004D49 + #DEECFF + #B5D2FD + #8CB5F9 + #6296F2 + #3A76E6 + #1E58D2 + #7B1E58D2 + #063BB8 + #002A98 + #001E73 + #00134D + #DEE0FF + #BCBEFF + #33BCBEFF + #9797FF + #7370FF + #4E48FF + #994E48FF + #3126E6 + #180AC7 + #0F00A2 + #0D0078 + #09004D + #E4DEFF + #C7B7FD + #AA90F9 + #8D68F2 + #7B47FF + #997B47FF + #7B7B47FF + #5923D2 + #4309B8 + #340098 + #290073 + #1C004D + #FBDEFF + #14FBDEFF + #33FBDEFF + #F2B7FD + #E690F9 + #D668F2 + #C241E6 + #A823D2 + #8A09B8 + #6E0098 + #520073 + #36004D + #FBDEFF + #FDB6D3 + #F98DBC + #F264A4 + #E63C8B + #D21F77 + #99D21F77 + #B80761 + #980050 + #73003E + #4D002A + #FFDEDE + #FFBCBC + #FF9696 + #F97372 + #EF4E4D + #E12A2A + #C2110E + #A00700 + #770800 + #4D0600 + #E8E4EB + #D4D0D8 + #AAA3B1 + #958E9E + #847D8B + #706A78 + #5C5565 + #484151 + #352E3E + #282233 + #211A2B + + + @color/white + @color/black + #7B270A + + + + + + #ffbac5d2 + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/dimens.xml b/VisualNovel/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..78e3401 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/dimens.xml @@ -0,0 +1,146 @@ + + + 16dp + 12dp + 12dp + 20dp + 8dp + 0.5dp + 4dp + 8dp + + 10sp + 12sp + 14sp + 16sp + 18sp + 20sp + + + 7dp + 3dp + + + 44dp + 16dp + 16dp + + + 0.00 + 0.02 + 0.04 + 0.06 + 0.08 + 0.12 + 0.15 + 0.20 + 0.25 + 0.30 + 0.45 + 0.5 + 0.65 + 0.85 + 1.00 + + 0% + 2% + 4% + 6% + 8% + 12% + 15% + 20% + 25% + 30% + 45% + 65% + 85% + 100% + + + LTR + TTB + LTTRB + + + Poppins + D-Din + + Poppins + Bangers + + 64sp + 48sp + 36sp + 32sp + 24sp + 23sp + 20sp + 18sp + 16sp + 14sp + 12sp + + 400 + 500 + 600 + 700 + + 8dp + 4dp + 6dp + 2dp + 2dp + 2dp + 3dp + 4dp + 3dp + 4dp + 0dp + + + + 1 + 1 + + + 4 + 3 + + + 3 + 2 + + + 2 + 1 + + + 16 + 9 + + + + 4dp + 8dp + 12dp + 16dp + 20dp + 24dp + 40dp + 42dp + 80dp + 999dp + + + 0.5dp + 1dp + 2dp + 4dp + + 24dp + 220dp + 240dp + 300dp + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/iconfontkey.xml b/VisualNovel/app/src/main/res/values/iconfontkey.xml new file mode 100644 index 0000000..1f04fbd --- /dev/null +++ b/VisualNovel/app/src/main/res/values/iconfontkey.xml @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/ids.xml b/VisualNovel/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..9a183b6 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/ids.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/strings.xml b/VisualNovel/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..513ab57 --- /dev/null +++ b/VisualNovel/app/src/main/res/values/strings.xml @@ -0,0 +1,466 @@ + + + VisualNovel + + @string/google_web_client_id + 793744761373-pkous5kso3843kmdpbo5id18obr17dii.apps.googleusercontent.com + fcm_default_channel + + %1$d contacts have new messages + New message from %1$s + %1$s sent a photo message + %1$s sent a voice message + %1$s sent a video message + %1$s sent a file message + %1$s shared a location + %1$s: Notification message + %1$s: Audio/video call + %1$s: Reminder message + %1$s: Custom message + %1$s: Unable to display this content + You received a new message + New message + + Your network is unstable, please try again + Loading… + Copy successful + Share + Search + support@VisualNovel + No permission yet + 语音时间太短 + + Image does not exist + The image cannot exceed 10MB + charge succeeded + + + + Video And Picture + Video Select + Picture Select + + Full + Gap + + All Medias + All Video + + You have denied the permission to take photos. If you refuse to set this function, it will not work! Do you want to set it + up? + + You have denied the storage permission. If you refuse to set, the function will be unavailable! Do you want to set it up? + + I refuse + OK + + Please select at least one file loading type! + Clipping is abnormal. The picture has been reset for you. Please try again! + This file has been selected or cannot be selected! + No media files found + Only pictures can be selected! + Only video can be selected! + Video duration shall not exceed + Video duration shall not be less than + Preview video not supported! + Only select one video! + Do not operate too fast! + + Today + This Week + This months + yyyy-MM + : + preview list isEmpty! + + All Picture + Choose + + + s + m + h + d + + Yesterday + MMM d, yyyy + MMM d + + + + + + + + + + + MMM d, HH:mm + MMM d, HH:mm:ss + MMM d, yyyy, HH:mm + HH:mm + MMM d, yyyy, HH:mm:ss + MMM yyyy + yyyy + Sec + Min + Upload up to %d images + Your image failed to upload, please try again + + + Confirm + Continue + Discord + Google + Cancel + Sign In With + Login Cancel + Personal Information + Please check the Google account login status + More + Optional + Download + sort by + no_more_data + Chat.novel.AI Date + Login or Signup + From \"Hello\" to \"I Do\", every conversation is full of heart + Nickname + Age + 请输入你的昵称 + 对角色的称呼 + Gender + Birthday + Please Select + Please Select Birthday + Select + Can\'t change after submit + Register + User Agreement + Privacy Policy + By clicking to register, you have read and agree to + and + The age cannot be less than 18 years old + 昵称只能包含2-20字符 + 该昵称已存在 + 只能包含%d-%d个字符 + 最多输入%d个字符 + ID + OK + Message + Friends + Membership + Diamonds + Creator + No Character Yet + Characters + Unlock more + Setting + Account + About + About Us + Log out + Submit + Male + Female + Nonconforming + Other + Tips + Delete + 角色删除成功 + Delete All + Read All + Delete Account + 账号删除后数据不可恢复。请确认是否删除该账号 + 账号删除成功 + Got it + Classification + Role + Original + 同人 + personality + tag + Next + Exit + AI Generate + 内容未保存,是否继续退出? + Character + 请描述角色的背景、性格、身份 + 请描述角色的聊天方式,对话语气 + 请描述角色的开场白 + 请选择角色语音音色 + Create + Complete + Dialogue style + Opening remarks + Voice + Character Voice + Select a voice from below + Voice Recommend + 请确认该虚拟角色是您的原创或同人创作,不侵犯他人的图像,IP或其他权利。 + Appearance + Regenerate + Click to generate images + Introduction + 为用户介绍该虚拟角色 + Avatar + Crop + Privacy + Public + Private + Generating + Generate Image + Image + Style + Description + 请描述形象的肤色、服饰、发型、五官、动作、背景等 + upload error + Warning + Profile + Not Now + Go + AI创建的内容会覆盖你已经填写的内容,请确认是否继续? + 前往个人主页,为角色创建相册,吸引对话者,增加收入。 + Edit + Album + Chat + Liked + Chats + People + Gifts + Unlock + No Album Yet + 图片删除后不可恢复。已经付费解锁过该图片的用户依然可以在角色的相册中看到该图片。 + 设置为默认图片后,图片的解锁方式只能为“免费” + 不可删除封面默认图片,该图片会作为在个人主页头图,卡片主图,聊天背景 + Unlock Method + 对话者通过付费方式查看虚拟角色的图片,可以增加创作者的收入分成 + VisualNovel平台会从每张图片的销售收入中,分成20%作为平台服务费 + 设置若干免费图片,可以吸引对话者与你的虚拟角色互动 + Free + Paid + Set as default image + Default Image + Set + Apply to personal homepage header, card main image, chat background + Reference + AI will create based on the basic image of the character + Non-members have 10 free creations + VIP Remaining Times + Remaining Times + Free Remaining Times + Buy VIP + 10/Month + Buy Times + Buy Credits + Price + Time + Quantity + Total Price + Confirm the work + Unlock Price + 解锁成功 + The price range is %d – %d + Press again to close app + Intro + Default + Save + Save Successful + Delete Role + 删除角色不可恢复。为保障用户体验,角色删除后,已经与该角色发生过聊天的或者付费行为的用户,还可以正常与该角色互动。 + 角色已被删除 + 该角色已被删除,无法访问和互动。 + Give up + 放弃创作 + 选择退出或重新生图片,已经创作的图片将消失,同时消耗1次创作次数。 + 选择退出或重新生图片,已经创作的图片将消失,不会退还原已经消耗的novel coin + Inappropriate image + Unlock Instructions + Low + High + Slow + Fast + Subscribe + Tap to Listen + Picture + Call + Chat Background + Notice + Title + 心动值总和排名:top %s %% + 心动值15.0℃以上可出现在关系列表 + 与你的心动值达到15.0℃以上的角色作为排名对象,按照这些角色心动值总和进行排名 + no_message_yet + no_friend_yet + no_result_yet + no_notice_yet + Delete Message + 删除消息后,将清空该聊天的消息记录 + 删除全部消息后,将清空所有的消息记录 + Check + Prompt + Heart member unlock + Member unlock + Hold to Talk + Release to send + My Chat Personal + Chat Setting + Auto play voice + Who i am + Chat Model + Chat Bubble + Unfilled + + Meet + Friend + Flirting + Couple + Married + Retrieve + Retrieve heart value + Hide Relations + Purchase + 内容由AI生成 + 相识%d天 | 心动分超过%s%%的对话者 + Deducted cardiac value: -%s℃ + 通过聊天或送礼增加心动值,24小时不联系心动值会自动扣减 + 虚拟角色会根据对话的情绪感受,酌情判断增加或者减少心动值 + 心动值会提升心动等级,通过升级解锁称号,功能,以及不同的角色对话阶段 + 虚拟角色对你的称呼 + *gender cannot be changed + 描述你所扮演角色的人物背景、性格特征 + 选择退出或重新生图片,已经创作的图片将消失,不会退还已消耗的novel Coin + No background yet + Stay tuned for more models + Role-playing model + Have a role-playing conversation with AI + 文本消息价格是指与角色进行文本消息对话的价格,含发送语音,含发送图片,发送礼物 + 语音通话消息价格是指与角色进行语音电话对话的价格,按条计算 + Gift + Copy + Like + Dislike + likes + Just now + Call Canceled + Call duration + Text Message + Send or play voice + min Voice call + Unlocked \"%s\" title + Losed the title of \"%s\" + Heartbeat %s unlock + Use + Hi + %d to Create + Create image + Unlocked + Waiting to be connected + Interrupt + Listening + Thinking + set background + Only apply to the background of your chat with the character + Generate Filed + Leaderboard + Leaderboards + 热聊榜以AI聊天会话数高低排名 + 心动榜以AI角色所有对话者产生的心动值之和的高低排名 + 礼物榜以AI角色所收到礼物打赏价值之和排名 + Heartbeat + 钻石可用于支付聊天费用,以及解锁其他道具。 + 断签后,会从第一天开始重新签到。 + Day + Check in + Checked + You have checked in for %d consecutive days + 今日已签到 + Filter + Type + Encounter + Not Start + Secret Admirer + Someone is secretly in love with you + Not Yet + You and %s are moved by each other + Thank you for your %d %s + Matched + Giving gifts greatly increases the probability of matching + Wallet + Recharge + novel Coin insufficient + The novel coin is insufficient and cannot continue, please recharge + Income + Balance + No Transaction Yet + Transaction Detail + 获得的收益30日后可提现 + I have read and agree to the + VisualNovel Top-Up Agreement + Creation Income + Pending + Please check connection of Google Account + VisualNovel VIP + Only %s/month, enjoy more benefits + Expiration Date + Terms of Service + You subscribed to Plus on App Store, please go to there to upgrade + You subscribed to Plus on PayPal, please go to there to upgrade + Off + Month + 创建虚拟角色次数已经用完辣 + 你的心动会员已自动续期成功,续期时间至 + 余额不足 + Dialog Model + Swipe to see more + No intention yet + 退出登录 + 请确认是否退出登录 + Dialog + Create high-quality AI characters and win novelCoin. + More Revenue + Stay tuned + Gift Revenue + The interlocutor can pay to send virtual gifts to your AI character. + Grow your love story with VisualNovel AI—From ‘Hi’ to ‘I Do\', sparked by every chat + At VisualNovel AI, every chat writes a new verse in your love epic—\nFrom that tentative \"Hi\" to the trembling \"I do\", find a home for the flirts you never sent, the responses you longed for, and the risky emotional gambles you feared to take. + Contact Us: support@VisualNovel.ai + All + + \ No newline at end of file diff --git a/VisualNovel/app/src/main/res/values/styles.xml b/VisualNovel/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..00be17c --- /dev/null +++ b/VisualNovel/app/src/main/res/values/styles.xml @@ -0,0 +1,233 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VisualNovel/app/src/main/res/values/themes.xml b/VisualNovel/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..079ffcd --- /dev/null +++ b/VisualNovel/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/VisualNovel/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp 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 VJa`tbMBwi~0h_6d;o!g4zX37bAXWeX literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_on.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_actor_on.webp new file mode 100644 index 0000000000000000000000000000000000000000..d723a28c00c97bbecb88b23db262ee035c506255 GIT binary patch literal 3234 zcmeHJdpMNq7XPGZizv2hiL@iLQEegDj@yt+65}#1xic7M%ruR0X;-R446?~>L^K$q za+yMu+HSdJ2AgqD$aUPA39~=-oTvTAKIeJPf9H?0-fyk<_pbGOzIUy6y??y6=BB0@ zf&k!PVrT=i(Q*_30N^yJK3_j`BO{w;9&icpH1FEIU?>=gLEAzN-TVkN)7V9&o`uTbu>KQ-- z?64&TY_Xcp1t}3Btb87qM~6XENtkePCt|L(w*_^|Q`6JsS3*KRCmt=!F3Z-*`;eKQ zjyFZLr#t)lN+xos*ytgI%}nurfeJt*H1k-AL@M54;F`{W1!8~}%vTvqpal~s^BNuJ zLUAE zhd9Uggea~SZa8+&{PLdTF$(SpiGof^ZV|4vTW?x5eUJl5PA(~y=8gGu6|uK_6M?RH z%z3x+fbFLG%3#j+YEfQoYAojCKOeabFBZL9LgP_zCS-Ml#aT95`q^eU zgysg{T#Yd^FCciBRPfc61t|uzvbK_FOxf)QW`kJp-th8*vDs{KmEj}s4oy;!B~~HN z0B(GJQ1zX}7t<*sI2yK(WEyj6t%EY|6rj(2gIK&kDD2H_+% zcGRZiqeB0n$?`LJNLNle$ny;y_nN zW#yM*qvG0^`TZH}E(07Pu(U4ANqMs9%+0Cv(-fB-#z@FX3g%Ec&+kVGzgUzjDxd_p zusVOeOS6xY^|os`fsnI`O2b+^1{Ns|mPuas_H-?{w7d(`t6wOU68(J=K#vbl=z$i* zXk7$Rl>b~t-OIkd2NY(BA@7^Y8KYcI>XRlhDX6jOiw-;de!_%^Rpx$F0jILJ=A2}w zz_C+>WT={c^;V&9H>J4ojri@4el+RJuz0sivCN@ zuavSHg&~*{N&Gk7<_&kusn_p;iX&>m0&Z@&9V=XZwzO;zu&H+Ew~-XAH} z*yV%tPV($ZdD@Hn^|I3C7nba_Wm#gaIqb%eWo;hnF$89`dGiPV?b@QY*ic2wNS zOB|Tld&HCs6V`ah)W^^Zo)5M4jpvNdk2Z}vPVOuw=3?@?C^`MHhtf%QDAy3;ff z&*?)1=>*hs=PDtuKZ;pv=X&$48e)+vIZrPxf%Vn#;ow_+-b0BFNmxCq07us*=~EPAtjFKIBp#-scm+#|tNUW$2RIC=!eE5Qm;+2|-E<%+PZagCMYCs02H!Qe z9NuYW=cd+J8lJy^s4ylW$OXcz&PhSi4!%-b#lP0Uef^Z$hkctT-NWK(a-)5iOA4cF zTI{(`HLWI7l243z4I?4*S=QT~?apJTTIsDx-FMaxxNqPub>&8O7#8e1?wa7kt?ChX z(2?=d&vDQbr{vL|MIhcLj(?gGED9_!@XL^dGEFlB4c0EZjkTLO= zhDV*Z**n55ln!!23|>$Q9~{P4mDAL@kSAmzYX21S*v28&BJ$FQ3jEz@cDg~hv5cL! zQ+3~f!6o%Ozyz6*I$N34{kh^Hs&r?Jc;mx(Leo|XQ((OiqBp!!+okiWZN-x6GOqmk zIp*&Ay_21dk3TihyRJra^&HleQ=u|vq;6hhTuvi$rc_3As(hFbKYLNS+m!-l(E8)|mv6yuok!v7Ot!}&)5 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_off.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_book_off.webp new file mode 100644 index 0000000000000000000000000000000000000000..79c76f302751146b291ab98665e895addbe470ce GIT binary patch literal 2308 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 EC!)^btpET3 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_off.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_off.webp new file mode 100644 index 0000000000000000000000000000000000000000..4c50497c30e8b1f53dcd38676ab3bf399d10d714 GIT binary patch literal 2218 zcmd5+dpML^7vJPk=u>o*6CtCJlzLo_VRUk-8NMEureQqB8O#ud$+(0Qox@SN=T`3J zxMbWCX3Ajb;xZ+bV=zNQC->w&3NyZU^n6d}kNW=p*4}&V-(KtYuD#Y?&+|GWZET9R z1AwtUZGXYu#90Oaa2#5Mt%wC@yyjzFwaq$MC1DIhq=@yuy;XBVux^av1x#+Kkc zDdaEcn;K&A*Q|dbHURWL0NBU*YJ=SYKt2`#TF+P8*Eb>KgYaA7Bp@wLBm%Hl z2Ea}i02E&VAmh5_gVc9&Q-@qCP+o!1@CN=+1JwZ%1OQK<51|uKQw@L-)M2}NDFBWU zPATu=NtMw^{VdM9aKn!Ulk}Ebz9XrqY}&YYZVK8i@7dTp{#1Df-T2y*w%&Ed-wTuD z3zLG0jM!(YRIN1;@HbM*Ew?a=S~ZSih>ba3pN1*{@0qIhPM>4of@x2J3{b(52O^ zRySn9kS{~gCDeL0RTm)BCG62@d9yGvSoUmMB^9LIuc9LEG?Y>Y^t-h14C=ufvsB+` zmDW(S)($T8@UWe!66!mUTG_=~KXJ7*%3*bGbNRE>_}Mg-`QNskZKQg@Pod3`;3^De zK5|tH3MzLSh6JEB{k#HFDpkT{1c_AuBOnbW4#39WSCP6|^P|&o7+zCSSi7@y;Dx)#}15S>`@+Kp!1hazg0{jw7hLjMb)N zeQvGe`VQzvgUjK~!c~kFn!tZ+TpS^Yc4wQHPQUpW?y~IQ8zwfVpI9Cd`7Jyar|ao_47g_m*zrhVA(5 z3;DWB`s;$+v5JO6hIl5+G>?R^(EvK{1P$oIswo&|vj1E9lM~>TuG`%MN-~Q~5&HR5H{bL&Vb` zoT&Mrr2VRY;+J=_1Ol%A0)@p}hMzb-`kMud)zlEEkLnJ1k}Rk4fxB*4ii~3#*kr45 zCKhOlw_-kGI(!hil~H;|2XMXS$362D;bq;)Z4c}I)SSF{lM*qXl4-CU+h5eTbW2my zfB!Ty%lI#PN5WWuWr%HAZD1^xAvNazGM|ZgU6iyl#85tN?XRg7Rn9VndNEQhzT0O# zPJ(%!ZU7%ovZ%P|yDF@amwO)*b~>KPTpCE&FV{HA*5MSYakB1VJ>^V(YI46^ZMd=0 zH9EQ(>+aeXZF)CKUP)%d>&X1ZfQ(J|vk-yPxN7SgRNRuFqMl4q+W6ySbms?eT3-%T z?-&U;oHLb(Qc5U+kM};;s}p~=)=H=HrQC`Cl8YkxAgb+}j->Dm>9%9*WBKmFyNfFG z9E2N1k8nmUc+(p#Tn=-FzC~jLQMT4V{X1Y#`L|w*~m|Tw|3a`jd^K+ Qo&MdQ(!53E=05TN0ASy6t^fc4 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_on.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/main_tab_manga_on.webp new file mode 100644 index 0000000000000000000000000000000000000000..91a9f7f151ad1629ee506dd19a455375855755ba GIT binary patch literal 2224 zcmd5+c~sL^7JeyE)Uu2VmQ7(ah-?N>kVTeJ1R`aN2@uLsmH#ZiDOAbWf6#>A}|j)J@)jp^Y^^v-tT?)JMZ21?(h6C zXiv}jO#lQRKN=7e;1IF}0ALL#VSS*F9Si72z$HL*uUS&4M%U_BWpM_ zJvAL_0@iRE7IO^ZScvz>{|-n04#%XXt?Bci=14LzW9_W9wst!W0tt5#diYMj5Rd@| zc!8s9_lIlPQmO!OPynDp|G-6;0l=jJpga13L*4*D`)dF=Pd;!T-Xt|DEoyx`RcJqo zivwV;4ggI&0NbAeu;ugh7&L#8n=uR-!n{&oj|W6pKx2RgBoG4*Kx7MxY6I+Hg+FWg z7yuN(UGHyPbu$Li3lkf3=Zfl;-Z^>m9bu+Rah-d|cgP+bR?JG61UxAp_0^Z}D0NX@ zj9<)|)A)PoAaYjnDbL|-n@~f<$v50~jXJ&lMoKxWPAL*8xlj0RWIwVWS5OU6ykW*t)R-@t_3Zt2YMKz{9 zg})#XNJmkI@)NqM?yOHJ!{m&K8UQ(mdytwAQgj+Xt9y4+(C-OI1bv4!Fy4#?z@35C z2Y`;;3_2(b1{P;xh8`j1eP4q#F%ylD+Qo5g%tsUklli0WRDpoWwDDx<#~^Rnc$SMB z2jYY)r-^xiID#{jvP6npCUa1f-0*lG8l4Df_Bg4qUw1D$S-BKc?hE%=*-DIWb1SJ? z!E#`}?%$4bo`2uZNmdSX^0SHMWxGY3_{FeiricX3$UCyPZzm=i;JdCW)C zE?T~t2G+NIusD*8UlX`G!a9y4ysZ>ss#nUnGdpp`C1rj72ZMMTi${J|oN$(YEjZ+%(2M=7XDrI{V$vD}PusoMo z`Gm~S&~Zxie6EGH9Gfuo;*_Lv7oMjz;W{Pasy(6(3V9k{nnahK?e{c-4_r&$>!hV? z^Ci#Dek@uUdON6LAd$V{gnqua@^%foojg-{_W4CWp7~99AG6;~VvWfK%hUa8hwF+k zT_;p~p7#Ef5evhmY|h^{ww(S}xHei?%K^zi2l&XdA=lgsZs<)XXMW z`<6$GPMeHO)CNgw!#l0|O39c+HRt%xiWGYpDb&TA+LCj!%F9G?0!hoHT+UzOMyyU1 z%BSo1DbfpUs2;P&`2DXGS<8}037^j}o_{#}_dSd4S?v)ii&Adz?)naj#+j1py;=Om z!%ckG>^I}q0$THnZ-kJ!JyhPlI4=}bRoFXpTFO07tvoO&7}NjH7=F)BRCMwC`Ns`P z+LkBxx9;R0@oqz{th{Rx^)}omHf`z+)4AEDZsdY}$83+jwaFrUdv(R~!rZl~24YxN zh*{_8kYN8QzFRnELXqSZBEO_dm|`)^Q$kETFLANnEDbDX!pgvN*m6Qz&}_@qN>2>W z?CBH`2-82?1>8_}KT!G?6}M1t$QDLWDsD%ppX1A@%0BOQ+$i#CQ+5G&PXzsbY3r}cQ|7-;?l|+McYMMa&>n`>q~a=o8IyQ&jxu$ z(nHz1Z^lvOB{_nbJZ<}Vn$vmVo-9e?T(E?fO4YF%oec;khj`3fl-N-cB<*j%Fv*ub zYtl=m(WL_Z)jnr!Ns#Z!w$}@;)^zPY4Kv;nEi>Xw?2jM2j5!@&njaXgtsT@=vEOtD zWhl`<_p)?$mOrkzEgfnca=4!Rk&$pqg>}`1K(g%8irKurJL{Z%e=Y9RGnwV0W_e`c z$l;_0I~+b@^nNR$*4F|bVkx#s!Nl2E1pE-}>QZvODUHq5bINxzE-#Mn8VUJBeHENap8?kGyk>`Ik dUQxCG@*hX(x#~Ct%}eF4{z=m8^i>;G@L!;6X?Fks literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_expand.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_expand.webp new file mode 100644 index 0000000000000000000000000000000000000000..a9d43f7bbd112858f11870579e294ae3a04bf89d GIT binary patch literal 1474 zcmWIYbaUIq%D@or>J$(bU=hK^z`$St#O`3^>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|Afzm$qafxmJu*g4H?XUG3;}OnSnvy{eYmr{zF=mQq=zzF|Z^su>N@* zA8K-NMX!qcy*Z3Jjm`!CANRkK*gEmaiP^>tss$YH>i-1FKHkc}zPiwZ7{0<65uq6eE+2s8>< z-oGxPtjLno^JGrswMA2$I2u!Rqq1Y}yKA=ojoViIVz021!crHG#>bT$iUP?j0y(#> z?(H$zmb-Mp^{{v?I`Tu|K_gYB@m;Z_ulccvPs-ToWHd3-91qS>FJyOg5Kp$c_z-FP^wqvJ#VRcXK%rO9)=Q&DMFGn80HyB?g>+N?ddZ0r1V_DE3%hPnK!?C#~vdts33v?XHkt~qROQ#VG*G5A=p zZqK^@XrHFFhL#y`zNXowUFXAY6jre@B%FGgZxQ+P#*(>HWzveo4J@A-{_c_u^t=$H zQQqc#DRKEYYy#LNj z+`ZE7#=m{{;_Cm&tT2jP*?+K2+aSdFPt~5imA@X`KHhb8`mD1WBJnGB?um(ykH264 z=hL41{l7(5r<=~tG&!mLGtPO)TtL4v;v>)*V)p*x1#0oIjw(B71*C1`TvSV zym?Kp#m|U~8~FL3UTNR5IYQvsjt@-Jy8Qm%_vb1)G;iv)j~6`Varm2W@0U+1I^3{2 zQP`k~`RVVkZ@p{ptT;b?+3cdI{zAtu=^cCA&cz@5C=@gMcf->|`}n>KuNHZevEz2v z*XX-d@4ep4+!lGJZ2p^>`~1u9l)bbx&))5~Xv>k1-dj^rP6i(Rc2sS3$hlKyNkRQC zA-`T6nDX}jYvrkN+A}uosgC`k?0tUQnHM*=Uc4T&bl&~ztH1Mey}n#O;fu3Ub8_yg zjq(q_IM11yTN&EJ yf<9-yIkPq-W|?Ko9F3!gO0Jx!lC^66b*s3sVwI6Ze)R9&?vD#5^|i5VbOZqEJ{_w7 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_shrink.webp b/VisualNovel/app/src/main/res/mipmap-xxhdpi/tag_flow_shrink.webp new file mode 100644 index 0000000000000000000000000000000000000000..79b2a3238cffb1c50291261f7a1a8cd2d67c3aea GIT binary patch literal 1452 zcmWIYbaPw6%D@or>J$(bU=hK^z`$St#O`3^>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|Afzm$qafxmJu*g4H?XUG3>LAnSnvy{eYmr{zF=mQq=zzF|Z^su>N@* zA8K-NMX!qcy*Z3Jjm`!CANRkK*gEmaiP^>tss$YH>i-1FKHkc}zPiwZ7{0<65uq6eE+2s8>< z-oGxPtjLno^JGrswMA2$I2u!Rqq1Y}yKA=ojoViIVz021!crHG#>bT$iUP?j0xS)> zDyjLox34AUKF)di-#^l4x#xFA!N2Vao_TlJSf>d#O;TXJ{)iJ6HXu5oU`O-HsLkea z3VO%3pN-ObuvL-~A>W`d^YBi$>b?#zuy}p-v!eC-l#^T3yj@&e_VzyAF?Hjnw-U8S zIhB-@7R||;^!3A?TfuTU0-1Z>P5D#uQtM)@qEb+6sC}Z9m&pBsF!7EKnL}HinFPF( z-0$e}L@4i3OY|`}X}?=Mi!b?2b8~Tda>pb3Nm$)6$6btp6CQmj)O@hz$lXO99a=3j z0^hIvxKK;#XsO(*Jr^qlUKi)hIh49eQE8RXs&FMQztBrciV^?p7J0p^+ET7FF^I`~ z(+UYw$yLoOrm^oUKDBP^k~eKjH>cXB#qz18J=?tDjKZqtJPbFYr#t8xJ7m->;QnFE zbnMThtlR&;|Gxcx{|U2`Ml+0Wyn0gpr{>oCdG;rdnabRgySQQh`S!PEyK8^^QJ!_Z zj6Lnh*N;2y&inDP`NZdhu2r^ryunjfO2t+u@ujXkxhOR-?*64MvtG+vOxbJbs%`N5 zgA&gv=jq2kDNK7Va7*yv?$50_ z6|M7Pw_Xu%ywswSmEt^!Q?YC9q|UH|M^9W^D%8K@%ZG~|qP+Y+Jowii4GFwbq4^__ z>%7>N2RFM9Uf<+lYxU&l@5MKso{`u1ED)Z1s!eLO-ptAliJNM_Z9dP;d*oGhNttQs z(sQ$F&O07GY`p(d%F55H7xkywl=HC~uif~@Cil{*2nqFKbrJDX*Q7Utxwmy6NbI-=>YnC?Uu^%*N007z@7zqFX literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/checkbox_normal.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/checkbox_normal.webp new file mode 100644 index 0000000000000000000000000000000000000000..d6062b70aa58ea63a7983b8c814ad9db2603da5c GIT binary patch literal 306 zcmV-20nPqWNk&F00RRA3MM6+kP&iB;0RR9mJHQSQhoCC54a)md5zS>1<)Vh-NHQSz zQz9}KlTa4(PcTUv+iXm-I%wT<}%`;glpvxC^$I(7g600000U$%3o43xecz&!v!PQq}PW9_E0gSSmJh#3n)DIl1Xz_WZz?M$)Z!& zl7agddq>%0$YSsD4Y84ZyLQaqHAGiAE+7=E_17MPCncUdxAfPD@j E0H(!`od5s; literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_female.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_female.webp new file mode 100644 index 0000000000000000000000000000000000000000..1cadf71e3d686889bc90e53150b46506aac3bacc GIT binary patch literal 1066 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 literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_nonconforming.png b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_gender_nonconforming.png new file mode 100644 index 0000000000000000000000000000000000000000..e6b18a8073f20d53ddfe64f0194662f8b7a99bf7 GIT binary patch literal 2222 zcmV;f2vPTmP)W!qBLd(NR`Kj67;XaS08j(qBETyV5MGXX zE86|3BJ&ILvbU!L=6rB01Va`$S0{%t4vq>i90qhH$orsd^><3Uxdif z&WVi2iz(TbSAVWQhhsOIwX&q~CNQ=ZxdpqGGE)Ws|3D98>t=BLPAOwgbV3a0z>QzU{4NuW9S_K%jDbkgY!qdF;n{fsklk_TjoLu z`7f&uB%R!2f*U(2g=ksyj2OA*qA!Qg2y|J71-Qx z9=&F(NqPeyM5o7#$=O(wNa+KxvFHdQIXR*uF57Le<1caR5w_IXqP8MRwoR`^(4 znDKKkcANd>o*Q_k<|qzbyW$amym1roM*3V#42^V5hI5W7r~iS9uInm6U~LEryiYDp z7zzMIc*f$RT7sPX*mUfh{GhC|2RrI;@f0^bQW- z?o;pK+rBQ9vv&+zfd%XKI5Uv~K#2{%RU;y{pBovEy_r9eEM+y?lj$t0`VWqZ2$U#y zU}Msq6DTPFr9~S7ysSo1xiRF-exEWO>yxwHt_F9q=&NsPIEO#gpLIA-L3+J{1?y;@ zmghDAl+T-b>iv<@P7Jxj;@I-s*thG%4t3O79OmWT?*z@+i4CRFL7^%3dcti0jBgdf zY~CVXqLm|g_njFA#07_Y6w%Vxg;mu?%WmPfN2hxphTm?Lu7J`a3d4j~a#BMg@J9MP zx8B{|Bx$#JrluTqy+R5_L>bF^bFUv;k1O6`XsFnH4hz5WpD9vzK62WV|oK+|r)tY{vZa`!T>(6@BFS zGPVu51%*}sN{cT5{KrDHwb}c@sd=*O=&K&Seie(Nr2)^u_Bt#ao8)%29xSQ(#*U&UwO{ySnNS)wx?Wqn_K*NVS3S7TRm75YVg zao0&QRzd)r0q6Jn3xLPQr($zj4${J+d=gRH-H8`!kK?0`I-kb*M9kQi1V9})pWxF# z1;jc4uiZ7>5L9QJ>nfm%G_*^6=%`O+EOi{W6YpHlk;6z``O_AAtE$iFhn}BBFyr z-L8c7mre)))QO2nH~?hx2c|qSL{SQWUcGV#uV4KNJ+|7QD^aLUk7tvnVZ(&`WCNnb z7d{c~69@otCP3c4>i+y84zJbJjYq#egwr?M6n()N5i$69Y93;P!X)(M7&E^p5x6^$ z2?%t8zfQ=)_KXK4O4FC0e{R3x$=e4L2uHG)3|R?lt3Ss(O%;lO^h#uLf&#a}lhdET z%*Z&2Tq<_HR9+%d_3Rki~X zJENafe2VwmYBlk-#Q_uwi%EtGlcr(I-E$;L{i5&dQigK}@OfvWCPg2JN`x_cv4qFghVyv7)~7y*p*lFyVR|K? zATr_ra1=xcfHLhRvFDPeVe3dT0ZB2P#}BtvcvM=fTc{djy>r^8;4$E~YCbAyD^f=HvS;UO_N z{QD}1cxjLz00gwi49k+3;uaYKL1U8?u^3)OQD2&*>`}AEHaR<+VNe!V(I)2rK}@6M z4|*`7x>3>rg4kABiWuidCE(8BF^IQyi!}Fj;oH7jIMUf9rqE&6U306f#VLw!me!OX zD;+GXS=s@DxORD^b5Knuv|Zj=3xO9VR8P+HKtElWkbp3nuguti7}b}i>>!NlYje&f wF={VPx;mp#dv#XME04;{)9$vXVSau7|Kal_<)jVM_y7O^07*qoM6N<$f~_DYsQ>@~ literal 0 HcmV?d00001 diff --git a/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/VisualNovel/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..aa7d6427e6fa1074b79ccd52ef67ac15c5637e85 GIT binary patch literal 3844 zcmV+f5Bu;^Nk&He4gdgGMM6+kP&il$0000G0002L006%L06|PpNQVLd01cqCZJQ!l zdEc+9kGs3OD-bz^9uc|AA8?1rA#x4f-93WH-QAt;uJ6U6Yp<>o!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbvOO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ zSbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPfidh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4P;c8$Q|KU?Joh zIkA^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zpU5ND^P*RoEkbD5o#az(-g=Y)L>HH>Oc%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=ep!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!isi6vTPLJ4@(|o=%NHYjo0_S&q*UQIROw@*N-By@PaQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjnx zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j#A4mU}enR_!cGmIYQ;qwfchWtFEXL)AK%*;=j znYne+hS4EMy3S)C*mZ1KI>!+)0V@9!N6H$Y}~MJ{rYuf zz^KljIWvFi-?#?V@LPR&c6Nn{!=XM z>}-h$S76;$H{E{Y%@^zlmOl^efBwa%UU+jJD9UVukQ3ti_kH-?H*RC0?M1W%FCvMB zM_+v6fk$6X2sx)-p~B3&Kl{nscK}pNLM*qjtpaf9>AU{-iPKQZR8yCg!TY}Qg*(;) z)gdvCcB%kppZc$VdvsK@)3l1{&DG!d_6OHOS`y=ITLEVu`unSKA2E%JD*DVX{LJ}K z9l>hMRDqxQh0lnpGHpVYneX}eA3Pt|2v%=q;rt)``R|#bDyB)OXY&vI_@|*}h}G?^ z@aZ4_!7cQPX`!fW_?{oT1NTwHs#l5L-0`E|y@48<3Q^HFf8=Idi zpJYD%1MkII!~|7I^WGo)IF=?{>ACnjJ_WUi39C}!Q{QnheVJqeKKqq5^o5CBde(g9 zvw$X6^jz_^E2$wSw4!q5*RG(C2_^XO$HBn_55vbl44OnTTRwRaePP0vo{K)U1#99& z<>rq7V&V(<&@I%MFoN5zrY}sz=(*-L&}1QQ*a%`u25h{cFj===17eB_uGuzG&byQ< zrm8BJZl4r_E$3k|Wo6FW0-6M7>qac5uFQsQcmkLWGfeH74S3Z_rJ!jgN++!@i=HW8 zkyjI(oPH-+-N#Qc^-mpNO`bc6r=2-<%&Wy5K1vfFJB(L_IkpS6fY^NmuL8qsgj>MD zn~BHH9WM~32_3vd=W&B)k7F9q%stJx+b_L_X-4zr^LVUMCmyCTA3sWtkvsmME?Xiy z?xOSfB=_$oY06~J-HcCq&)qcW{j;uP;?Dm}=hkq?zh&n!;m((-G-u_t|6x399Q;>A zgNpxoJNj{u|MFDH7Rhq@FCAl0dE|ddnl!oh9{Lq?@JDoR6L;C941IK`ISfdE$4S zE0AUQ8+2|Ncl_q5QkSp#AODp~(^mfP&%Au@@|TBQwoP`UU+V{6u8|)6ZA{~uKmQ*M zmrMTDU8S~8Eqi{^v0Ug&5Upcm#y7Z1(RbgZAG8jB$eRwCspQ)>5;U)oGZ&E5aeR*K z8Yt`Y0$G))Yd(Y3KH}tA4`-_QmNke5hU_|nq=xtyjwW(_o?itz>B>WM&^63bNdQ)k@-IgDHW*RW$Xo9#RzrTrCn7L2H{9Amq|qNg@#eZY=|P zCoI?2s+L)zsM%WX(NbVEY^`C>lFjIBYmJ6@DKJ0ZT4&F&WHW!dwa%QzOG!?jY_2(S zDcEzZbz*2Q!43|z))9yOP9X1Xt%DXzwY(3tl-TR=Qb_MbZYRrooh;dYYmS!U_as1(=YVB?Q_A|tNu5Ut&_q3jbfDM zoFxT^uEuH`nX3*sB%K?GuHUkweYReBwnHqh3P)~`+s3+Tj!rDA1e)8vuBv5J*IsxC zkd^~b(aGzArj08{>cnzOuy04C+C`}gb|Yz-1avxeWzev3NzcHbz_&4W@QCr$z3~w=8Ua- z`;vfG1~BP8CyLb=F7t1am~ph_#|O%$khSJ9%Vtcn)YmpgQxF?xM^_Vb+5fnpB^W0I`f%X8gb9#X{Q-yJG0{Z56aWeI&zPxnf5pdJA38bM`cYnS#x)% z`n1tFf$i)W-hGm(f9mde^=X@NcV_lFb=P`4&CI&H=IArijGwdCk&X@uQ$5xmj!~^? z#$ROCI)V-~t%L%GS#wo@U27ddR`4`3)WoB{R-4snfNrfee|kI8^bu#yDgYqOwas9# zmcb`3!kRJ`Cr=_tq)8aMt{aGtUZsqwVlj6DgCGre>AEt&x8H_in!x@uwgExIh|-mA zjdaC(29~CTVSaaF7HPbql&*9Uo8P@f)>LqCXclr}peS7_1BQ28u9PO8Eq1@`l3q9o zkfKCaO2?T?ZyA6loW<#9_c^O=m<&h}CA!ineAD@=(gbq`vyT|tiJ6#^B1$P;;qax` z55k&Q?wEh#87niLo*+n4L@65J(Nz~=Ya%7^(miLb(E>A3B@|Jjl;FU&D>o|9#7PJH z?|ago!o;WC^h=|T7PVBg(DAB}72cyUS zb(f>Bwbr!F1eTCO5fpj<{PqhY5>143p?~5ZA5H40);=@M#MYvrB6gqHbU_!GSY??i z%s=>-ciA4*zOOZHds0a(kWewZ4h(k8h(ua7HX)Au&mY~H8KY6(_cb$_&fA@QjIW-*heP3%$d!m5^AdnT}`12qA^c@!g3DOwZ5WwE2?)-yU z!)Vx#Mtxt?FzFTwK!77sy7)sMzUd->w4^bxtpM2j!b1pjgyk zGKwWGeb4)^zjy{9Es&PU1}gwg?|J#L$KJB7ett9@4M%-nGtIQr0>Fl@8-yh`-+1ed zS6r}(MeSvgSoFmH*_WPu@i?}!AB~2?;i&IxrkNg~cQ9Som98tcq)k^|eeER|Zl77t za-TVUc;DNvzVXJ%w52+#weN?+;i#{f#!Oc&z?81*N>^e~ltRS%ZI@lR{rs()HmqG! zx*}ZrI-EZ}ckJMiy>A^oofwDfC~IH)z8{VHKGT@#E5I(Ll&+MnMCl>~AV7+>Gi%mF zkU1QlKASdR0B80!YhP<$Ywi0?W2Ux45oPfxv9QolWzJPD^weBfvo4SONxP35106sAmh(e+vAs0GboFD@PvNs)jNPvarhW}0YliZEg{Gazv z+JDIpoojRVPr<*C|BTq<`6ga{5q^8^!|0cxe=rZ!zxH3%f5ZO0cQ*Z<^$Yt2{|Ek0 zyT|*F+CO@K;(owBKtGg!S^xj-Z~rga2m6nxKl9J=fBSuNKW_dLKWhJKeg^-Xe`^1? z`TyJj)8E!#>_3Y?uKrwqq3LJ#SGU>AzUO|6`nR^u&3FNN_jGOc zw)Nw`wr3yIKhgcee6IaN=ws>M{6677%)hPwx&HzC(f&u~&)6@b2kNRzBDQAP0*H73 zq%McOmRk{B3i47qRe=DA*$&odrbEJZ*pV9XXa&p@wlW~@Yfs>V{yiTtplMhgM*-Bz zsSnlq&pG;z0OUN%$~$3=g1UF+G*>+17eRbBf3=y79J}KR8owon@$1Z7MIrvvWWH)34nK2SD)GsrJ{l z1Cl#oVo3A8qY3e=aF)qzms~FG#2$LzT=gs&aVMOj>(%{y<&O0cG!nCiESl~x=^dF{ zKvj8F1K8Ng171wwM5Fh4KoQw`_c6#y$(5cAm7e}~nJ#A*fx+c9;y#&W!#VukR)ugk zKp3=+;Ut+IYn%m+r4d*<`L2h%aDnX5}^!5R|H;(34AoVWjRx(msBZvk;rCI*|~ zdOijqI@9Z{Vu!~jvHW{lBa$rnl4+!s_5sfK3bCGk-B%iDe&@-}+%fOKU|(9?V1 zHE8&@4z)Kx!RAvAs z!Wic9=o#(bg?kc-G68-m(jZ`^=XGUXb)}t(%&~sjFnV^sEX%hSy6UKC4iOhgV=BHV z2w`4g7Y=s#Vu2B_?#VQ|hP39@eArgfX>-0S+dd&^mx0*wp}>)x;c4RUgxz%;oNe?& z-7-lJ@Y^2^C;=qJsxx5|xF)*pTGhch2B&kxtn;f!7=gznk}I3}Dh}(CoMXgA5-p&kS202!l?!fT3t|HG*rIP~mS* z$Wjo}jq3}z$Qq!9yrtd3fM0N629ZM?LU$nv@Tv9b7I;D|;0H2dsA~g7Z7zp1| zB)XmrkMgF6OQr|R)HHD^TE{Y#j!~SR?b`Xt3Qs`B+x<hxexYeAjMUWdZ-*n9%(1)Wb(n2U<><7&9dwGJmrob)4%H? zlQ%z+L-^$dFhhH|@u$%97Qz?*Ynh2VG@q|?8vY&L74&fs&_b&3$x&Oyjl~LQDRRap zJU4U*R+(2Dd!G+lh8!V{pT_UJn+^1Qg6$` zqkNm(a#hWyc6SP+p5=C4HL8-m`pO`5o~`-LI?_h5CsH?F_%?nDodmz&pWR20WTpJE z?N|wSzLjMUK8E)a2tI}Lf;+;*M|h3Y(U#>)g1>zk9|Hd}oZAa2 zLYBWBoSW!Ts!RwXr^8h+U*@{9{zqS^iH)Op<;r`Uw~nc}<^$V~_i%$GFjaG?X1@E|M`h)nekvFKt`Dh-f>@|0-`Xoq)o` zx;JmzDfOV9qCx|EVpogEe0LK~tGS?5$$L_i6P$P6wIsCQaP_;d{{N=iV@+8LI}o#( zvo*Ejy=IIn{rdIQh1&q-{EuohpVOjJ^Q3lD*YTp37$^RRgn8ihpdu5{Ct%5-KO!VL zcNB6dUajXI9jkm-P|i3~GB-A(X`P1Oqqb$tcku)UJw0w3GeUijb__#QT4j%64z%EeB7S?jlWwx_7&+EEvB|6N=kV}DwnyAlX=?j`) zmU#!$*^@NIu#n_d7;WoJV@*Fbv9|yJO4;n|BNF2xy(54RyB>t~8lUOUW$&2%Nwi1y zx6JxW88>U2$#qhl^6KUbtmg9}D0o5vYDT7kWJthLGkpGnN4T>{St^_EU>4;DmLF9o zr|LqsA8_MoNLQ=}w?8u!ziSZ@PC#Y<#9uJFo-ozVo6D;<8j^1$c|qAE3ZTE5i~zmE z$BU5lw6l=EWsg^y^;8>r9qH{xfL|~PZYK#md$zZ0?o11gV<*WSW~cgy2GYGQir%wf zt4iW8D+;s*;RGrmd(-T<@2&j(Cb9xhV*l-x`TpK`xq|7p?5R%5*s!69?2c!cC*VY* z2DE^9pvOPLU!1e}wA8S8opcTJ3`NB>hY=JQnL~QFXR4K8A$BqJnoEB$wn-%u@E6Mh zCfMF4kusv3N!(aHC}4)Xs^xoOwXd%e^6pi5|DZo=Q25j+6HlJ^7FodH6y1bMROR^q zGu6)fopS`h%Sw<;ZH%TEPf+#81-#_v+@8nlR0jLcIDKQtLleOC)6yLZgC!D9X3GgS zohwU{v$jl=quD#Go^hB{`@Qw*a%`(^jyT~=q^bWgGzRj;|12J55HWdCWV}EB|K=%N z3Nq-qxJJ`>^|1MNN+q}zTB&ooE3j==AgK@^UW<^oSbeALa2peF)Th6{@sj0KyMNHZ zksk1+MXN2tv+22A%cQOGpS9)77(uP9mh+!5T5ERLvF@b}$+WvXM45Z?-kCa)fb~f1 znVbTD$Gx-0Zxc`0D@YgHakge6SL0H`-vN_x?AP0>iGH0_EE&=v83hMJgaKAI0jJXm zVxVz;X<$v6WW7}fxROO7vr#YLP;;lij5VrX{;>7kK6TtOH&6|Ar^xo>00%+u$C4@# z>!jOt6*3><171+WxoZnKDTzJtDRw+T030;yI}~uV@9fCnei^I*j>Bp&mzP2d=FPb_ zCM*l_+$LDR3B*a!A$g#>xsrZvw0lckxmMg>0aQd7tPyN=t{dgXb;Ie+T8{fZH=gdu zM7Rg9c(kg(Jg0?ARRRl=AONFKrvFj)lTY$KfT%6^6s`mk*ABGhsce*LsoD>K{z_M2 ziPpnu+lw22PfF!CoId^6n*G4H(Ix+#+N{C(da7t1BYMGEaE#PdpOLxsVD5riQXHp@OX;`S`8VnpM~)I920w~<3|mo0 zf8~Az`*?2?H&gZ&*K&bRkV@qzvMlRHXys8*Ze2+1c?5o!^+$&MHxB@4Ee5cke52R! zmn7AZtY6ST%ixgU5)%$%QcwHj7Es-Qu^kLAPwy%7pGBw_4Q9#da^W2$}axNHr03)_nw z5?yuNmXrI5HgS46)c5&}B)Tts49oU92>3xBLLy}FMUW=84DQbVq^;7_e7|(Sdz|&J z73N+M`rc2rt*oSWu#7S{*s~nH6HRHJS1SmzeXk|;CA)FI4bat3<%}nkB%;;?=F>B7ms9QSxv#@+69;@>QaR?REYX4&)=itG>rM{<{A79Rmk)`5ON#GL`*KX%}Ihk3w(RtM-WLt z?f&FLF}4N^yE!(pZ&Yj&Bc`~K0@4_}*0Om?wN|}4WJ>WL;G^H2*QpgEkGA~OET-Km zkwz|5{6dnz1U<2Pe9DNL>3g5FEIvp1jzP&2K#z~j%g6!7B;^zF+o95?fV{3mnB8*RMhCDNp>Am-3e@jNfMj?jHV$MWjk!DDKP zkAz$Y?Sr)!GUOX}qTQ5aMh|wq1uq}~joWyKl=b_LboM#wi{CMuz5x6BKlA-qy++cM01D3b7`uD z#l6M4pI;JCypO8JZ6?U&wNxR!{4oB_ zlV!x9+-&Qy6{%MQ{~yoZGkKiTSC`YS_j22~G;xUV855g2&C(zm^V!(wpcm@zn{%!g z4}JGo(sGZ1O~to-}le