From d1afadb45b806b0a465b2401b8fabb65fc080cf8 Mon Sep 17 00:00:00 2001 From: IacobIonut01 Date: Fri, 16 Aug 2024 20:19:28 +0300 Subject: [PATCH] Merge changes last nightly This is the last nightly before the 3.0.0 release Expect bugs (do not report yet) The built-in image editor is currently disabled as is facing a major re-work Includes, but not limited to: - Settings for auto-hide search/navigation bar - Switched from coil to sketch for image loading - Switched from zoomable to zoomimage/sketch for image subsampling - Reduced unecessary recompositions - Added total 'Images & Videos' counter at the bottom of the Album screen - Added total Album media size after counter (eg: 1011 items (628.20MB)) - Improved Album sorting UI - Improvements to Vault (now limited to Images only) - Improvements to Ignored Albums * New Setup UI for creating new Ignored Albums * Added wildcard patterns (Regex) for hiding multiple albums at once * Added visibility option for hiding the albums (and it's medie content) either only in timeline, album screens or both * Added more information about the created Ignored Albums - Re-arranged Settings screen - Updated major dependecies - Added avif support - Improved video player Signed-off-by: IacobIonut01 --- app/build.gradle.kts | 30 +- app/compose_compiler_config.conf | 20 + app/proguard-rules.pro | 3 +- .../3.json | 80 +++ .../4.json | 80 +++ app/src/main/AndroidManifest.xml | 11 +- .../main/kotlin/com/dot/gallery/GalleryApp.kt | 43 +- .../kotlin/com/dot/gallery/core/Constants.kt | 6 +- .../com/dot/gallery/core/MediaObserver.kt | 7 + .../kotlin/com/dot/gallery/core/Settings.kt | 31 +- .../dot/gallery/core/coil/ThumbnailDecoder.kt | 135 ----- .../gallery/core/decoder/SketchHeifDecoder.kt | 203 +++++++ .../gallery/core/decoder/SketchJxlDecoder.kt | 197 +++++++ .../gallery/core/decoder/ThumbnailDecoder.kt | 109 ++++ .../core/presentation/components/AppBar.kt | 116 ++-- .../presentation/components/EmptyMedia.kt | 38 +- .../components/FilterComponent.kt | 105 ++-- .../presentation/components/LoadingMedia.kt | 53 +- .../presentation/components/NavigationComp.kt | 66 ++- .../presentation/components/SetupWizard.kt | 195 +++++++ .../components/util/OnLifecycleEvent.kt | 2 +- .../components/util/StickyHeaderGrid.kt | 5 +- .../com/dot/gallery/core/util/SettingsExt.kt | 33 ++ .../data/data_source/BlacklistDao.kt | 14 +- .../data/data_source/InternalDatabase.kt | 13 +- .../data/data_source/MediaQuery.kt | 5 + .../feature_node/data/data_types/GetAlbums.kt | 4 + .../data/data_types/MediaStoreCursor.kt | 3 + .../data/repository/MediaRepositoryImpl.kt | 56 +- .../feature_node/domain/model/Album.kt | 4 +- .../domain/model/BlacklistedAlbum.kt | 16 - .../feature_node/domain/model/IgnoredAlbum.kt | 70 +++ .../feature_node/domain/model/Media.kt | 16 +- .../domain/repository/MediaRepository.kt | 8 +- .../domain/use_case/BlacklistUseCase.kt | 10 +- .../feature_node/domain/util/Converters.kt | 13 + .../feature_node/domain/util/MediaOrder.kt | 11 +- .../feature_node/domain/util/OrderType.kt | 13 +- .../presentation/albums/AlbumsScreen.kt | 37 +- .../presentation/albums/AlbumsViewModel.kt | 45 +- .../albums/components/AlbumComponent.kt | 45 +- .../albums/components/PinnedAlbumsCarousel.kt | 15 +- .../presentation/common/MediaScreen.kt | 7 +- .../common/components/MediaGridView.kt | 16 +- .../common/components/MediaImage.kt | 29 +- .../common/components/OptionSheet.kt | 4 +- .../presentation/edit/EditActivity.kt | 7 +- .../presentation/edit/EditScreen.kt | 2 +- .../presentation/edit/EditViewModel.kt | 13 +- .../edit/components/filters/FilterSelector.kt | 17 +- .../components/filters/TextureComponent.kt | 96 ++++ .../edit/components/utils/BitmapUtil.kt | 105 ++++ .../edit/components/utils/BoxHelper.kt | 39 ++ .../utils/BrushDrawingStateListener.kt | 54 ++ .../utils/BrushViewChangeListener.kt | 16 + .../edit/components/utils/CustomEffect.kt | 64 +++ .../edit/components/utils/Emoji.kt | 58 ++ .../edit/components/utils/GLToolbox.kt | 95 ++++ .../edit/components/utils/Graphic.kt | 76 +++ .../edit/components/utils/GraphicManager.kt | 99 ++++ .../components/utils/MultiTouchListener.kt | 267 ++++++++++ .../components/utils/OnPhotoEditorListener.kt | 68 +++ .../edit/components/utils/OnSaveBitmap.kt | 12 + .../edit/components/utils/PhotoEditor.kt | 374 +++++++++++++ .../utils/PhotoEditorImageViewListener.kt | 44 ++ .../edit/components/utils/PhotoEditorView.kt | 171 ++++++ .../components/utils/PhotoEditorViewState.kt | 77 +++ .../edit/components/utils/PhotoFilter.kt | 42 ++ .../edit/components/utils/PhotoSaverTask.kt | 92 ++++ .../edit/components/utils/SaveFileResult.kt | 10 + .../edit/components/utils/SaveSettings.kt | 79 +++ .../components/utils/ScaleGestureDetector.kt | 497 ++++++++++++++++++ .../edit/components/utils/Sticker.kt | 43 ++ .../edit/components/utils/Text.kt | 61 +++ .../edit/components/utils/TextStyleBuilder.kt | 259 +++++++++ .../edit/components/utils/TextureRenderer.kt | 163 ++++++ .../edit/components/utils/Vector2D.kt | 28 + .../edit/components/utils/ViewType.kt | 18 + .../components/utils/shapes/AbstractShape.kt | 42 ++ .../utils/shapes/ArrowPointerLocation.kt | 3 + .../components/utils/shapes/BrushShape.kt | 34 ++ .../edit/components/utils/shapes/LineShape.kt | 103 ++++ .../edit/components/utils/shapes/OvalShape.kt | 42 ++ .../components/utils/shapes/RectangleShape.kt | 42 ++ .../edit/components/utils/shapes/Shape.kt | 11 + .../components/utils/shapes/ShapeAndPaint.kt | 11 + .../components/utils/shapes/ShapeBuilder.kt | 60 +++ .../edit/components/utils/shapes/ShapeType.kt | 14 + .../edit/components/utils/text/TextBorder.kt | 8 + .../edit/components/utils/text/TextShadow.kt | 8 + .../edit/components/views/DrawingView.kt | 231 ++++++++ .../edit/components/views/FilterImageView.kt | 94 ++++ .../edit/components/views/ImageFilterView.kt | 269 ++++++++++ .../edit/components/views/PhotoEditorImpl.kt | 319 +++++++++++ .../presentation/exif/AddAlbumSheet.kt | 8 +- .../presentation/exif/CopyMediaSheet.kt | 3 +- .../presentation/exif/MetadataEditSheet.kt | 2 +- .../presentation/exif/MoveMediaSheet.kt | 8 +- .../presentation/ignored/IgnoredScreen.kt | 112 +++- .../presentation/ignored/IgnoredState.kt | 5 +- .../presentation/ignored/IgnoredViewModel.kt | 14 +- .../ignored/setup/IgnoredSetup.kt | 161 ++++++ .../ignored/setup/IgnoredSetupDestination.kt | 10 + .../ignored/setup/IgnoredSetupStage.kt | 8 + .../ignored/setup/IgnoredSetupState.kt | 12 + .../ignored/setup/IgnoredSetupViewModel.kt | 81 +++ .../presentation/ignored/setup/IgnoredType.kt | 10 + .../ignored/{ => setup}/SelectAlbumSheet.kt | 17 +- .../setup/screens/SetupConfirmationScreen.kt | 127 +++++ .../ignored/setup/screens/SetupLabelScreen.kt | 85 +++ .../setup/screens/SetupLocationScreen.kt | 154 ++++++ .../setup/screens/SetupTypeRegexScreen.kt | 179 +++++++ .../setup/screens/SetupTypeSelectionScreen.kt | 240 +++++++++ .../presentation/library/LibraryScreen.kt | 13 +- .../presentation/main/MainActivity.kt | 22 +- .../presentation/mediaview/MediaViewScreen.kt | 75 ++- .../mediaview/components/AppBar.kt | 22 +- .../mediaview/components/BottomBar.kt | 21 +- .../components/media/MediaPreviewComponent.kt | 24 +- .../components/media/ZoomablePagerImage.kt | 129 ++--- .../mediaview/components/video/VideoPlayer.kt | 161 +++--- .../components/video/VideoPlayerController.kt | 85 ++- .../presentation/picker/PickerActivity.kt | 5 - .../picker/components/PickerMediaScreen.kt | 2 +- .../presentation/search/MainSearchBar.kt | 262 ++++----- .../presentation/settings/SettingsScreen.kt | 65 ++- .../settings/components/SettingsItems.kt | 7 + .../presentation/setup/SetupScreen.kt | 217 +++----- .../standalone/StandaloneActivity.kt | 11 +- .../presentation/support/SupportSheet.kt | 2 +- .../presentation/timeline/TimelineScreen.kt | 2 +- .../presentation/trashed/TrashedScreen.kt | 4 +- .../trashed/components/TrashDialog.kt | 15 +- .../components/TrashedViewBottomBar.kt | 4 + .../presentation/util/ContextExt.kt | 9 +- .../presentation/util/FileUtils.kt | 13 + .../presentation/util/JxlDecoder.kt | 127 ----- .../feature_node/presentation/util/Screen.kt | 3 +- .../presentation/util/newImageLoader.kt | 51 -- .../presentation/vault/VaultScreen.kt | 5 +- .../vault/components/DeleteVaultSheet.kt | 2 +- .../components/EncryptedMediaGridView.kt | 4 +- .../vault/components/EncryptedMediaImage.kt | 32 +- .../vault/components/EncryptedTrashDialog.kt | 16 +- .../vault/components/NewVaultSheet.kt | 2 +- .../vault/components/SelectVaultSheet.kt | 2 +- .../EncryptedMediaViewScreen.kt | 4 +- .../components/BottomBar.kt | 2 +- .../components/media/MediaPreviewComponent.kt | 3 +- .../components/media/ZoomablePagerImage.kt | 54 +- .../components/video/VideoPlayer.kt | 3 +- .../components/video/VideoPlayerController.kt | 265 ---------- .../com/dot/gallery/injection/AppModule.kt | 10 - .../ui/core/icons/RegularExpression.kt | 87 +++ .../kotlin/com/dot/gallery/ui/theme/Theme.kt | 71 ++- .../awxkee/avifcoil/decoder/Heif3Decoder.kt | 145 ----- .../zoomable/coil3/CoilI3mageSource.kt | 259 --------- .../zoomable/coil3/canBeSubSampled.kt | 57 -- .../telephoto/zoomable/coil3/imageFormats.kt | 55 -- app/src/main/res/drawable/ic_remove.xml | 10 + .../main/res/drawable/rounded_border_tv.xml | 15 + .../res/layout/view_photo_editor_image.xml | 33 ++ .../res/layout/view_photo_editor_text.xml | 34 ++ app/src/main/res/values/attrs.xml | 6 + app/src/main/res/values/strings.xml | 59 +++ build.gradle.kts | 1 + gradle/libs.versions.toml | 32 +- settings.gradle.kts | 2 + 168 files changed, 7981 insertions(+), 2235 deletions(-) create mode 100644 app/compose_compiler_config.conf create mode 100644 app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json create mode 100644 app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json delete mode 100644 app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/core/decoder/ThumbnailDecoder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/BlacklistedAlbum.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/TextureComponent.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BitmapUtil.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BoxHelper.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushDrawingStateListener.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushViewChangeListener.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/CustomEffect.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Emoji.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GLToolbox.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Graphic.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GraphicManager.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/MultiTouchListener.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnPhotoEditorListener.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnSaveBitmap.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditor.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorImageViewListener.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorView.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorViewState.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoFilter.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoSaverTask.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveFileResult.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveSettings.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ScaleGestureDetector.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Sticker.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Text.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextStyleBuilder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextureRenderer.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Vector2D.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ViewType.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/AbstractShape.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ArrowPointerLocation.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/BrushShape.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/LineShape.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/OvalShape.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/RectangleShape.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/Shape.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeAndPaint.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeBuilder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeType.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextBorder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextShadow.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/DrawingView.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/FilterImageView.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/ImageFilterView.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/PhotoEditorImpl.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetup.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupDestination.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupStage.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupState.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt rename app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/{ => setup}/SelectAlbumSheet.kt (88%) create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupConfirmationScreen.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLabelScreen.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLocationScreen.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeRegexScreen.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeSelectionScreen.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt delete mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/ui/core/icons/RegularExpression.kt delete mode 100644 app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt delete mode 100644 app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt delete mode 100644 app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt delete mode 100644 app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt create mode 100644 app/src/main/res/drawable/ic_remove.xml create mode 100644 app/src/main/res/drawable/rounded_border_tv.xml create mode 100644 app/src/main/res/layout/view_photo_editor_image.xml create mode 100644 app/src/main/res/layout/view_photo_editor_text.xml create mode 100644 app/src/main/res/values/attrs.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a163fa6f2d..667d0bda01 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,16 +10,17 @@ plugins { alias(libs.plugins.baselineProfilePlugin) alias(libs.plugins.kotlin.compose.compiler) id("kotlin-parcelize") + alias(libs.plugins.kotlinSerialization) } android { namespace = "com.dot.gallery" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "com.dot.gallery" minSdk = 30 - targetSdk = 34 + targetSdk = 35 versionCode = 30013 versionName = "3.0.0" @@ -136,6 +137,7 @@ dependencies { implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material.icons.extended) + implementation(libs.androidx.graphics.shapes) // Compose - Shimmer implementation(libs.compose.shimmer) @@ -155,6 +157,8 @@ dependencies { implementation(libs.kotlinx.coroutines.core) runtimeOnly(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + // Dagger - Hilt implementation(libs.androidx.hilt.navigation.compose) implementation(libs.dagger.hilt) @@ -169,14 +173,18 @@ dependencies { implementation(libs.room.ktx) // Coil - implementation(libs.coil.compose) - implementation(libs.coil.svg) - implementation(libs.coil.gif) - implementation(libs.coil.video) implementation(libs.jxl.coder.coil) - implementation(libs.coil.network.okhttp) implementation(libs.avif.coder.coil) + // Sketch + implementation(libs.sketch.compose) + implementation(libs.sketch.view) + implementation(libs.sketch.animated) + implementation(libs.sketch.extensions.compose) + implementation(libs.sketch.http.ktor) + implementation(libs.sketch.svg) + implementation(libs.sketch.video) + // Exo Player implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) @@ -188,9 +196,6 @@ dependencies { // Exif Interface implementation(libs.androidx.exifinterface) - // Zoomable - implementation(libs.zoomable) - // Datastore Preferences implementation(libs.datastore.prefs) @@ -204,7 +209,7 @@ dependencies { implementation(libs.pinchzoomgrid) // Subsampling - implementation(libs.zoomable.image.coil) + implementation(libs.zoomimage.sketch) // Splashscreen implementation(libs.androidx.core.splashscreen) @@ -213,6 +218,9 @@ dependencies { implementation(libs.androidx.security.crypto) implementation(libs.androidx.biometric) + // Composables - Core + implementation(libs.core) + // Tests testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/app/compose_compiler_config.conf b/app/compose_compiler_config.conf new file mode 100644 index 0000000000..958cd16c3b --- /dev/null +++ b/app/compose_compiler_config.conf @@ -0,0 +1,20 @@ +com.github.panpf.sketch.Sketch +com.github.panpf.sketch.PlatformContext +com.github.panpf.sketch.drawable.DrawableEqualizer +com.github.panpf.sketch.request.Image +com.github.panpf.sketch.request.ImageOptions +com.github.panpf.sketch.request.ImageRequest +com.github.panpf.sketch.request.ImageResult +com.github.panpf.sketch.state.ColorDrawableStateImage +com.github.panpf.sketch.state.CurrentStateImage +com.github.panpf.sketch.state.DrawableStateImage +com.github.panpf.sketch.state.ErrorStateImage +com.github.panpf.sketch.state.MemoryCacheStateImage +com.github.panpf.sketch.state.IconAnimatableStateImage +com.github.panpf.sketch.state.IconStateImage +com.github.panpf.sketch.state.StateImage +com.github.panpf.sketch.state.ThumbnailMemoryCacheStateImage +com.github.panpf.sketch.util.ColorFetcher +com.github.panpf.sketch.util.Equalizer +com.github.panpf.sketch.util.IntColor +com.github.panpf.sketch.util.Size \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e128aaecea..388918f8bf 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,4 +22,5 @@ -dontwarn org.bouncycastle.jsse.** -dontwarn org.conscrypt.** --dontwarn org.openjsse.** \ No newline at end of file +-dontwarn org.openjsse.** +-dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json new file mode 100644 index 0000000000..06dd7d8b75 --- /dev/null +++ b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/3.json @@ -0,0 +1,80 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "b923fda23747db68de4db23fb2360ff8", + "entities": [ + { + "tableName": "pinned_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "blacklist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wildcard", + "columnName": "wildcard", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "matchedAlbums", + "columnName": "matchedAlbums", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b923fda23747db68de4db23fb2360ff8')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json new file mode 100644 index 0000000000..741cc9a748 --- /dev/null +++ b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/4.json @@ -0,0 +1,80 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "b923fda23747db68de4db23fb2360ff8", + "entities": [ + { + "tableName": "pinned_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "blacklist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `wildcard` TEXT, `location` INTEGER NOT NULL DEFAULT 0, `matchedAlbums` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wildcard", + "columnName": "wildcard", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "matchedAlbums", + "columnName": "matchedAlbums", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b923fda23747db68de4db23fb2360ff8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5242b88909..7a3846560c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,7 +60,7 @@ @@ -73,7 +73,7 @@ android:name=".feature_node.presentation.standalone.StandaloneActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" + android:configChanges="keyboard|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode" android:theme="@style/Theme.Gallery"> @@ -119,7 +119,7 @@ android:name=".feature_node.presentation.picker.PickerActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" + android:configChanges="keyboard|keyboardHidden|screenSize|screenLayout|smallestScreenSize|uiMode" android:theme="@style/Theme.Gallery"> @@ -138,7 +138,7 @@ @@ -149,7 +149,8 @@ diff --git a/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt b/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt index a27f120a11..17ff90ee94 100644 --- a/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt +++ b/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt @@ -6,7 +6,48 @@ package com.dot.gallery import android.app.Application +import com.dot.gallery.core.decoder.supportHeifDecoder +import com.dot.gallery.core.decoder.supportJxlDecoder +import com.github.panpf.sketch.PlatformContext +import com.github.panpf.sketch.SingletonSketch +import com.github.panpf.sketch.Sketch +import com.github.panpf.sketch.cache.DiskCache +import com.github.panpf.sketch.cache.MemoryCache +import com.github.panpf.sketch.decode.supportAnimatedGif +import com.github.panpf.sketch.decode.supportAnimatedHeif +import com.github.panpf.sketch.decode.supportAnimatedWebp +import com.github.panpf.sketch.decode.supportSvg +import com.github.panpf.sketch.decode.supportVideoFrame +import com.github.panpf.sketch.http.KtorStack +import com.github.panpf.sketch.request.supportPauseLoadWhenScrolling +import com.github.panpf.sketch.request.supportSaveCellularTraffic +import com.github.panpf.sketch.util.appCacheDirectory import dagger.hilt.android.HiltAndroidApp +import okio.FileSystem @HiltAndroidApp -class GalleryApp : Application() \ No newline at end of file +class GalleryApp : Application(), SingletonSketch.Factory { + + override fun createSketch(context: PlatformContext): Sketch = Sketch.Builder(this).apply { + httpStack(KtorStack()) + components { + supportSaveCellularTraffic() + supportPauseLoadWhenScrolling() + supportSvg() + supportVideoFrame() + supportAnimatedGif() + supportAnimatedWebp() + supportAnimatedHeif() + supportHeifDecoder() + supportJxlDecoder() + } + val diskCache = DiskCache.Builder(context, FileSystem.SYSTEM) + .directory(context.appCacheDirectory()) + .maxSize(150 * 1024 * 1024).build() + + resultCache(diskCache) + downloadCache(diskCache) + memoryCache(MemoryCache.Builder(context).maxSizePercent(0.75).build()) + }.build() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/Constants.kt b/app/src/main/kotlin/com/dot/gallery/core/Constants.kt index ce904d00e6..a7c0620f5a 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/Constants.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/Constants.kt @@ -113,14 +113,16 @@ object Constants { GridCells.Fixed(4), GridCells.Fixed(3), GridCells.Fixed(2), - GridCells.Fixed(1), + GridCells.Fixed(1) ) val albumCellsList = listOf( + GridCells.Fixed(7), + GridCells.Fixed(6), GridCells.Fixed(5), GridCells.Fixed(4), GridCells.Fixed(3), GridCells.Fixed(2), - GridCells.Fixed(1), + GridCells.Fixed(1) ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt index 304ebf213f..3de2144e9a 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt @@ -9,12 +9,14 @@ import android.content.Context import android.database.ContentObserver import android.net.Uri import android.os.FileObserver +import com.dot.gallery.feature_node.data.data_source.InternalDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -43,6 +45,11 @@ fun Context.contentFlowObserver(uris: Array) = callbackFlow { } }.conflate().onEach { if (!it) delay(1000) } +fun Context.contentFlowWithDatabase(uris: Array, database: InternalDatabase) = merge( + contentFlowObserver(uris), + database.getBlacklistDao().getBlacklistedAlbums() +) + private var observerFileJob: Job? = null fun Context.fileFlowObserver() = callbackFlow { diff --git a/app/src/main/kotlin/com/dot/gallery/core/Settings.kt b/app/src/main/kotlin/com/dot/gallery/core/Settings.kt index f45f39efec..f0341d8b6b 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/Settings.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/Settings.kt @@ -7,6 +7,7 @@ package com.dot.gallery.core import android.content.Context import android.os.Build +import android.os.Parcelable import android.provider.MediaStore import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable @@ -19,15 +20,18 @@ import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.dot.gallery.core.Settings.PREFERENCE_NAME +import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.util.rememberPreference +import com.dot.gallery.feature_node.domain.util.OrderType import com.dot.gallery.feature_node.presentation.util.Screen import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCE_NAME) @@ -36,11 +40,18 @@ object Settings { const val PREFERENCE_NAME = "settings" object Album { - private val LAST_SORT = intPreferencesKey("album_last_sort") + private val LAST_SORT = stringPreferencesKey("album_last_sort_obj") + + @Serializable + @Parcelize + data class LastSort( + val orderType: OrderType, + val kind: FilterKind + ): Parcelable @Composable fun rememberLastSort() = - rememberPreference(key = LAST_SORT, defaultValue = 0) + rememberPreference(key = LAST_SORT, defaultValue = LastSort(OrderType.Descending, FilterKind.DATE)) @Composable fun rememberAlbumGridSize(): MutableState { @@ -50,7 +61,7 @@ object Settings { context.getSharedPreferences("ui_settings", Context.MODE_PRIVATE) } var storedSize = remember(prefs) { - prefs.getInt("album_grid_size", 3) + prefs.getInt("album_grid_size", 5) } return remember(storedSize) { @@ -201,6 +212,18 @@ object Settings { @Composable fun rememberAllowVibrations() = rememberPreference(key = ALLOW_VIBRATIONS, defaultValue = true) + + private val AUTO_HIDE_SEARCHBAR = booleanPreferencesKey("auto_hide_searchbar") + + @Composable + fun rememberAutoHideSearchBar() = + rememberPreference(key = AUTO_HIDE_SEARCHBAR, defaultValue = true) + + private val AUTO_HIDE_NAVIGATIONBAR = booleanPreferencesKey("auto_hide_navigationbar") + + @Composable + fun rememberAutoHideNavBar() = + rememberPreference(key = AUTO_HIDE_NAVIGATIONBAR, defaultValue = true) } } diff --git a/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt deleted file mode 100644 index 59dffa2227..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/coil/ThumbnailDecoder.kt +++ /dev/null @@ -1,135 +0,0 @@ -@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - -package com.dot.gallery.core.coil - -import android.content.ContentResolver -import android.graphics.Bitmap -import android.graphics.Paint -import androidx.core.graphics.applyCanvas -import androidx.core.graphics.createBitmap -import androidx.core.graphics.drawable.toDrawable -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.asImage -import coil3.decode.ContentMetadata -import coil3.decode.DecodeResult -import coil3.decode.DecodeUtils -import coil3.decode.Decoder -import coil3.decode.ImageSource -import coil3.fetch.SourceFetchResult -import coil3.request.Options -import coil3.request.bitmapConfig -import coil3.size.Precision -import coil3.size.Size -import coil3.size.pxOrElse -import coil3.svg.internal.MIME_TYPE_SVG -import coil3.svg.isSvg -import coil3.toAndroidUri -import kotlin.math.roundToInt - -/** - * A [Decoder] that uses [ContentResolver.loadThumbnail] to fetch and decode their thumbnail from MediaStore. - */ -class ThumbnailDecoder( - private val source: ImageSource, - private val options: Options, -) : Decoder { - - @OptIn(ExperimentalCoilApi::class) - override suspend fun decode(): DecodeResult { - val metadata = source.metadata as ContentMetadata - val bitmap = options.context.contentResolver.loadThumbnail( - metadata.uri.toAndroidUri(), - options.size.toAndroidSize(), - null - ) - val normalizedBitmap = normalizeBitmap(bitmap, options.size) - - - return DecodeResult( - image = normalizedBitmap.toDrawable(options.context.resources).asImage(), - isSampled = true, - ) - } - - - /** Return [inBitmap] or a copy of [inBitmap] that is valid for the input [options] and [size]. */ - private fun normalizeBitmap(inBitmap: Bitmap, size: Size): Bitmap { - // Fast path: if the input bitmap is valid, return it. - if (isConfigValid(inBitmap, options) && isSizeValid(inBitmap, options, size)) { - return inBitmap - } - - // Slow path: re-render the bitmap with the correct size + config. - val scale = DecodeUtils.computeSizeMultiplier( - srcWidth = inBitmap.width, - srcHeight = inBitmap.height, - dstWidth = size.width.pxOrElse { inBitmap.width }, - dstHeight = size.height.pxOrElse { inBitmap.height }, - scale = options.scale, - ).toFloat() - val dstWidth = (scale * inBitmap.width).roundToInt() - val dstHeight = (scale * inBitmap.height).roundToInt() - val safeConfig = when { - options.bitmapConfig == Bitmap.Config.HARDWARE -> Bitmap.Config.ARGB_8888 - else -> options.bitmapConfig - } - - val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) - val outBitmap = createBitmap(dstWidth, dstHeight, safeConfig) - outBitmap.applyCanvas { - scale(scale, scale) - drawBitmap(inBitmap, 0f, 0f, paint) - } - inBitmap.recycle() - - return outBitmap - } - - private fun isConfigValid(bitmap: Bitmap, options: Options): Boolean { - return bitmap.config != Bitmap.Config.HARDWARE || - options.bitmapConfig == Bitmap.Config.HARDWARE - } - - private fun isSizeValid(bitmap: Bitmap, options: Options, size: Size): Boolean { - if (options.precision == Precision.INEXACT) return true - val multiplier = DecodeUtils.computeSizeMultiplier( - srcWidth = bitmap.width, - srcHeight = bitmap.height, - dstWidth = size.width.pxOrElse { bitmap.width }, - dstHeight = size.height.pxOrElse { bitmap.height }, - scale = options.scale, - ) - return multiplier == 1.0 - } - - private fun Size.toAndroidSize(fallbackWidth: Int = 200, fallbackHeight: Int = 200) = - android.util.Size( - width.pxOrElse { fallbackWidth }, - height.pxOrElse { fallbackHeight } - ) - - class Factory : Decoder.Factory { - - override fun create( - result: SourceFetchResult, - options: Options, - imageLoader: ImageLoader, - ): Decoder? { - if (!isApplicable(result)) return null - return ThumbnailDecoder(result.source, options) - } - - private fun isApplicable(result: SourceFetchResult): Boolean { - return with(result) { - mimeType != null && mimeType!!.isVideoOrImage && - source.metadata is ContentMetadata && !isSvg(result) - } - } - - private val String.isVideoOrImage get() = startsWith("video/") || startsWith("image/") - - private fun isSvg(result: SourceFetchResult) = result.mimeType == MIME_TYPE_SVG || DecodeUtils.isSvg(result.source.source()) - - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt new file mode 100644 index 0000000000..73c1d75030 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt @@ -0,0 +1,203 @@ +package com.dot.gallery.core.decoder + +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.graphics.PostProcessor +import android.os.Build +import com.github.panpf.sketch.ComponentRegistry +import com.github.panpf.sketch.asSketchImage +import com.github.panpf.sketch.decode.DecodeResult +import com.github.panpf.sketch.decode.Decoder +import com.github.panpf.sketch.decode.ImageInfo +import com.github.panpf.sketch.decode.internal.calculateSampleSize +import com.github.panpf.sketch.decode.internal.createInSampledTransformed +import com.github.panpf.sketch.decode.internal.isSmallerSizeMode +import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.animatedTransformation +import com.github.panpf.sketch.request.bitmapConfig +import com.github.panpf.sketch.request.colorSpace +import com.github.panpf.sketch.request.internal.RequestContext +import com.github.panpf.sketch.source.AssetDataSource +import com.github.panpf.sketch.source.ByteArrayDataSource +import com.github.panpf.sketch.source.ContentDataSource +import com.github.panpf.sketch.source.DataSource +import com.github.panpf.sketch.source.ResourceDataSource +import com.github.panpf.sketch.transform.AnimatedTransformation +import com.github.panpf.sketch.transform.flag +import com.github.panpf.sketch.util.Size +import com.radzivon.bartoshyk.avif.coder.HeifCoder +import com.radzivon.bartoshyk.avif.coder.PreferredColorConfig +import com.radzivon.bartoshyk.avif.coder.ScaleMode +import okio.ByteString.Companion.encodeUtf8 +import okio.buffer +import java.nio.ByteBuffer + +fun ComponentRegistry.Builder.supportHeifDecoder(): ComponentRegistry.Builder = apply { + addDecoder(SketchHeifDecoder.Factory()) +} + +class SketchHeifDecoder( + private val requestContext: RequestContext, + private val dataSource: DataSource, +) : Decoder { + + private val coder = HeifCoder(requestContext.request.context) + + class Factory : Decoder.Factory { + + override val key: String + get() = "HeifDecoder" + + override fun create(requestContext: RequestContext, fetchResult: FetchResult): Decoder? { + val source = fetchResult.dataSource.openSource().buffer() + return if (AVAILABLE_BRANDS.any { source.rangeEquals(4, it) }) + SketchHeifDecoder(requestContext, fetchResult.dataSource) + else null + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Factory + } + + override fun hashCode(): Int { + return this@Factory::class.hashCode() + } + + override fun toString(): String = key + + companion object { + private val MIF = "ftypmif1".encodeUtf8() + private val MSF = "ftypmsf1".encodeUtf8() + private val HEIC = "ftypheic".encodeUtf8() + private val HEIX = "ftypheix".encodeUtf8() + private val HEVC = "ftyphevc".encodeUtf8() + private val HEVX = "ftyphevx".encodeUtf8() + private val AVIF = "ftypavif".encodeUtf8() + private val AVIS = "ftypavis".encodeUtf8() + + private val AVAILABLE_BRANDS = listOf(MIF, MSF, HEIC, HEIX, HEVC, HEVX, AVIF, AVIS) + } + } + + override suspend fun decode(): Result = runCatching { + val request = requestContext.request + val sourceData = dataSource.openSource().buffer().readByteArray() + val source = when (dataSource) { + is AssetDataSource -> { + ImageDecoder.createSource(request.context.assets, dataSource.fileName) + } + + is ResourceDataSource -> { + ImageDecoder.createSource(dataSource.resources, dataSource.resId) + } + + is ContentDataSource -> { + ImageDecoder.createSource(request.context.contentResolver, dataSource.contentUri) + } + + is ByteArrayDataSource -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ImageDecoder.createSource(dataSource.data) + } else { + ImageDecoder.createSource(ByteBuffer.wrap(dataSource.data)) + } + } + + else -> { + dataSource.getFileOrNull() + ?.let { ImageDecoder.createSource(it.toFile()) } + ?: throw Exception("Unsupported DataSource: ${dataSource::class}") + } + } + + val bitmapConfig = requestContext.request.bitmapConfig?.getConfig(null) + + var mPreferredColorConfig: PreferredColorConfig = when (bitmapConfig) { + Bitmap.Config.ALPHA_8 -> PreferredColorConfig.RGBA_8888 + Bitmap.Config.ARGB_8888 -> PreferredColorConfig.RGBA_8888 + else -> PreferredColorConfig.DEFAULT + } + if (bitmapConfig == Bitmap.Config.RGBA_F16) { + mPreferredColorConfig = PreferredColorConfig.RGBA_F16 + } else if (bitmapConfig == Bitmap.Config.HARDWARE) { + mPreferredColorConfig = PreferredColorConfig.HARDWARE + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && bitmapConfig == Bitmap.Config.RGBA_1010102) { + mPreferredColorConfig = PreferredColorConfig.RGBA_1010102 + } + + var imageInfo: ImageInfo? = null + var inSampleSize = 1 + var imageDecoder: ImageDecoder? = null + try { + ImageDecoder.decodeDrawable(source) { decoder, info, _ -> + imageDecoder = decoder + imageInfo = ImageInfo( + width = info.size.width, + height = info.size.height, + mimeType = info.mimeType, + ) + val size = requestContext.size!! + val precision = request.precisionDecider.get( + imageSize = Size(info.size.width, info.size.height), + targetSize = size, + ) + inSampleSize = calculateSampleSize( + imageSize = Size(info.size.width, info.size.height), + targetSize = size, + smallerSizeMode = precision.isSmallerSizeMode() + ) + decoder.setTargetSampleSize(inSampleSize) + + request.colorSpace?.let { + decoder.setTargetColorSpace(it) + } + + // Set the animated transformation to be applied on each frame. + decoder.postProcessor = request.animatedTransformation?.asPostProcessor() + } + } finally { + imageDecoder?.close() + } + val transformeds: List? = + if (inSampleSize != 1) listOf(createInSampledTransformed(inSampleSize)) else null + + if (requestContext.size == Size.Origin) { + val originalImage = + coder.decode( + sourceData, + preferredColorConfig = mPreferredColorConfig + ) + return@runCatching DecodeResult( + image = originalImage.asSketchImage(), + imageInfo = imageInfo!!, + dataFrom = dataSource.dataFrom, + resize = requestContext.computeResize(requestContext.size!!), + transformeds = transformeds, + extras = null + ) + } + + val resize = requestContext.computeResize(imageInfo!!.size) + val originalImage = + coder.decodeSampled( + sourceData, + resize.size.width, + resize.size.height, + preferredColorConfig = mPreferredColorConfig, + scaleMode = ScaleMode.FIT, + ) + + DecodeResult( + image = originalImage.asSketchImage(), + imageInfo = imageInfo!!, + dataFrom = dataSource.dataFrom, + resize = resize, + transformeds = transformeds, + extras = null + ) + } + + private fun AnimatedTransformation.asPostProcessor() = + PostProcessor { canvas -> transform(canvas).flag } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt new file mode 100644 index 0000000000..2017336875 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt @@ -0,0 +1,197 @@ +package com.dot.gallery.core.decoder + +import android.graphics.ImageDecoder +import android.graphics.PostProcessor +import android.os.Build +import com.awxkee.jxlcoder.JxlCoder +import com.awxkee.jxlcoder.JxlResizeFilter +import com.github.panpf.sketch.ComponentRegistry +import com.github.panpf.sketch.asSketchImage +import com.github.panpf.sketch.decode.DecodeResult +import com.github.panpf.sketch.decode.Decoder +import com.github.panpf.sketch.decode.ImageInfo +import com.github.panpf.sketch.decode.internal.calculateSampleSize +import com.github.panpf.sketch.decode.internal.createInSampledTransformed +import com.github.panpf.sketch.decode.internal.isSmallerSizeMode +import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.animatedTransformation +import com.github.panpf.sketch.request.colorSpace +import com.github.panpf.sketch.request.internal.RequestContext +import com.github.panpf.sketch.source.AssetDataSource +import com.github.panpf.sketch.source.ByteArrayDataSource +import com.github.panpf.sketch.source.ContentDataSource +import com.github.panpf.sketch.source.DataSource +import com.github.panpf.sketch.source.ResourceDataSource +import com.github.panpf.sketch.transform.AnimatedTransformation +import com.github.panpf.sketch.transform.flag +import com.github.panpf.sketch.util.Size +import okio.BufferedSource +import okio.ByteString.Companion.toByteString +import okio.buffer +import java.nio.ByteBuffer + +fun ComponentRegistry.Builder.supportJxlDecoder(): ComponentRegistry.Builder = apply { + addDecoder(SketchJxlDecoder.Factory()) +} + +class SketchJxlDecoder( + private val requestContext: RequestContext, + private val dataSource: DataSource, +) : Decoder { + + class Factory : Decoder.Factory { + + override val key: String + get() = "JxlDecoder" + + override fun create(requestContext: RequestContext, fetchResult: FetchResult): Decoder? { + val source = fetchResult.dataSource.openSource().buffer() + return if (isJXL(source)) + SketchJxlDecoder(requestContext, fetchResult.dataSource) + else null + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Factory + } + + override fun hashCode(): Int { + return this@Factory::class.hashCode() + } + + override fun toString(): String = key + + private fun isJXL(source: BufferedSource): Boolean { + return source.rangeEquals(0, MAGIC_1) || source.rangeEquals( + 0, + MAGIC_2 + ) + } + + companion object { + private val MAGIC_1 = byteArrayOf(0xFF.toByte(), 0x0A).toByteString() + private val MAGIC_2 = byteArrayOf( + 0x0.toByte(), + 0x0.toByte(), + 0x0.toByte(), + 0x0C.toByte(), + 0x4A, + 0x58, + 0x4C, + 0x20, + 0x0D, + 0x0A, + 0x87.toByte(), + 0x0A + ).toByteString() } + } + + override suspend fun decode(): Result = kotlin.runCatching { + val request = requestContext.request + val sourceData = dataSource.openSource().buffer().readByteArray() + val source = when (dataSource) { + is AssetDataSource -> { + ImageDecoder.createSource(request.context.assets, dataSource.fileName) + } + + is ResourceDataSource -> { + ImageDecoder.createSource(dataSource.resources, dataSource.resId) + } + + is ContentDataSource -> { + ImageDecoder.createSource(request.context.contentResolver, dataSource.contentUri) + } + + is ByteArrayDataSource -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ImageDecoder.createSource(dataSource.data) + } else { + ImageDecoder.createSource(ByteBuffer.wrap(dataSource.data)) + } + } + + else -> { + dataSource.getFileOrNull() + ?.let { ImageDecoder.createSource(it.toFile()) } + ?: throw Exception("Unsupported DataSource: ${dataSource::class}") + } + } + + + var imageInfo: ImageInfo? = null + var inSampleSize = 1 + var imageDecoder: ImageDecoder? = null + try { + ImageDecoder.decodeDrawable(source) { decoder, info, _ -> + imageDecoder = decoder + imageInfo = ImageInfo( + width = info.size.width, + height = info.size.height, + mimeType = info.mimeType, + ) + val size = requestContext.size!! + val precision = request.precisionDecider.get( + imageSize = Size(info.size.width, info.size.height), + targetSize = size, + ) + inSampleSize = calculateSampleSize( + imageSize = Size(info.size.width, info.size.height), + targetSize = size, + smallerSizeMode = precision.isSmallerSizeMode() + ) + decoder.setTargetSampleSize(inSampleSize) + + request.colorSpace?.let { + decoder.setTargetColorSpace(it) + } + + // Set the animated transformation to be applied on each frame. + decoder.postProcessor = request.animatedTransformation?.asPostProcessor() + } + } finally { + imageDecoder?.close() + } + val transformeds: List? = + if (inSampleSize != 1) listOf(createInSampledTransformed(inSampleSize)) else null + + if (requestContext.size == Size.Origin) { + val originalImage = + JxlCoder.decode( + sourceData + ) + return@runCatching DecodeResult( + image = originalImage.asSketchImage(), + imageInfo = imageInfo!!, + dataFrom = dataSource.dataFrom, + resize = requestContext.computeResize(requestContext.size!!), + transformeds = transformeds, + extras = null + ) + } + + val resize = requestContext.computeResize(imageInfo!!.size) + val originalImage = + JxlCoder.decodeSampled( + sourceData, + resize.size.width, + resize.size.height, + scaleMode = com.awxkee.jxlcoder.ScaleMode.FIT, + jxlResizeFilter = JxlResizeFilter.BILINEAR + ) + + DecodeResult( + image = originalImage.asSketchImage(), + imageInfo = imageInfo!!, + dataFrom = dataSource.dataFrom, + resize = resize, + transformeds = transformeds, + extras = null + ) + + } + + + private fun AnimatedTransformation.asPostProcessor() = + PostProcessor { canvas -> transform(canvas).flag } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/decoder/ThumbnailDecoder.kt b/app/src/main/kotlin/com/dot/gallery/core/decoder/ThumbnailDecoder.kt new file mode 100644 index 0000000000..3861168eda --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/ThumbnailDecoder.kt @@ -0,0 +1,109 @@ +/*package com.dot.gallery.core.decoder + +import coil3.decode.DecodeUtils +import coil3.svg.isSvg +import com.github.panpf.sketch.ComponentRegistry +import com.github.panpf.sketch.asSketchImage +import com.github.panpf.sketch.decode.DecodeResult +import com.github.panpf.sketch.decode.Decoder +import com.github.panpf.sketch.decode.ImageInfo +import com.github.panpf.sketch.decode.internal.calculateSampleSize +import com.github.panpf.sketch.decode.internal.createInSampledTransformed +import com.github.panpf.sketch.decode.internal.isSmallerSizeMode +import com.github.panpf.sketch.fetch.FetchResult +import com.github.panpf.sketch.request.internal.RequestContext +import com.github.panpf.sketch.source.ContentDataSource +import com.github.panpf.sketch.source.DataSource +import com.github.panpf.sketch.util.MimeTypeMap +import com.github.panpf.sketch.util.Size +import okio.buffer + + +fun ComponentRegistry.Builder.supportThumbnailDecoder(): ComponentRegistry.Builder = apply { + addDecoder(ThumbnailDecoder.Factory()) +} + +class ThumbnailDecoder( + private val requestContext: RequestContext, + private val dataSource: DataSource, +) : Decoder { + + class Factory : Decoder.Factory { + + override val key: String + get() = "ThumbnailDecoder" + + override fun create(requestContext: RequestContext, fetchResult: FetchResult): Decoder? { + val mimeType = fetchResult.mimeType + return if ( + mimeType != null && + mimeType.isVideoOrImage && + !isSvg(fetchResult) && + fetchResult.dataSource is ContentDataSource + ) + ThumbnailDecoder(requestContext, fetchResult.dataSource) + else null + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Factory + } + + override fun hashCode(): Int { + return this@Factory::class.hashCode() + } + + override fun toString(): String = key + + private val String.isVideoOrImage get() = startsWith("video/") || startsWith("image/") + + private fun isSvg(result: FetchResult) = + result.mimeType == MIME_TYPE_SVG || DecodeUtils.isSvg( + result.dataSource.openSource().buffer() + ) + + + companion object { + private const val MIME_TYPE_SVG = "image/svg+xml" + } + } + + override suspend fun decode(): Result = runCatching { + val request = requestContext.request + val dataSource = (dataSource as ContentDataSource) + + val size = requestContext.size + val bitmap = request.context.contentResolver.loadThumbnail( + dataSource.contentUri, + android.util.Size(size!!.width, size.height), + null + ) + val mimeType = MimeTypeMap.getMimeTypeFromUrl(dataSource.contentUri.toString()).toString() + val imageSize = Size(bitmap.width, bitmap.height) + val precision = request.precisionDecider.get( + imageSize = imageSize, + targetSize = size, + ) + val inSampleSize = calculateSampleSize( + imageSize = imageSize, + targetSize = requestContext.size!!, + smallerSizeMode = precision.isSmallerSizeMode() + ) + + DecodeResult( + image = bitmap.asSketchImage(), + imageInfo = ImageInfo( + width = bitmap.width, + height = bitmap.height, + mimeType = mimeType + ), + dataFrom = dataSource.dataFrom, + resize = requestContext.computeResize(requestContext.size!!), + transformeds = if (inSampleSize != 1) listOf(createInSampledTransformed(inSampleSize)) else null, + extras = null + ) + } + +} +*/ \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt index ed932eae86..e9a2257aa7 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -43,8 +44,7 @@ import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,6 +60,9 @@ import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import com.dot.gallery.R +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.core.Settings.Misc.rememberAutoHideNavBar import com.dot.gallery.core.Settings.Misc.rememberOldNavbar import com.dot.gallery.feature_node.presentation.util.NavigationItem import com.dot.gallery.feature_node.presentation.util.Screen @@ -91,27 +94,30 @@ fun rememberNavigationItems(): List { } } +@Stable @Composable fun AppBarContainer( windowSizeClass: WindowSizeClass, navController: NavController, - bottomBarState: MutableState, + bottomBarState: Boolean, paddingValues: PaddingValues, - isScrolling: MutableState, + isScrolling: Boolean, content: @Composable () -> Unit, ) { - val backStackEntry = navController.currentBackStackEntryAsState() + val backStackEntry by navController.currentBackStackEntryAsState() val bottomNavItems = rememberNavigationItems() - val useNavRail = remember { - windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact + val useNavRail by remember(windowSizeClass) { + mutableStateOf(windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact) } val useOldNavbar by rememberOldNavbar() - if (useOldNavbar) { - Box { - val showNavRail by remember(useNavRail, bottomBarState.value) { - mutableStateOf(useNavRail && bottomBarState.value) - } + AnimatedVisibility( + visible = useOldNavbar, + enter = enterAnimation, + exit = exitAnimation + ) { + Box(modifier = Modifier.fillMaxSize()) { + val showNavRail = remember(useNavRail, bottomBarState) { useNavRail && bottomBarState } AnimatedVisibility( visible = showNavRail, enter = slideInHorizontally { it * -2 }, @@ -124,8 +130,8 @@ fun AppBarContainer( ) } val animatedPadding by animateDpAsState( - targetValue = remember(useNavRail, bottomBarState.value) { - if (useNavRail && bottomBarState.value) 80.dp else 0.dp + targetValue = remember(useNavRail, bottomBarState) { + if (useNavRail && bottomBarState) 80.dp else 0.dp }, label = "animatedPadding" ) @@ -134,8 +140,9 @@ fun AppBarContainer( ) { content() } - val showClassicNavbar by remember(useNavRail, isScrolling.value, bottomBarState.value) { - mutableStateOf(!useNavRail && bottomBarState.value && !isScrolling.value) + val hideNavBarSetting by rememberAutoHideNavBar() + val showClassicNavbar by remember(useNavRail, isScrolling, bottomBarState, hideNavBarSetting) { + mutableStateOf(!useNavRail && bottomBarState && (!isScrolling || !hideNavBarSetting)) } AnimatedVisibility( modifier = Modifier.align(Alignment.BottomCenter), @@ -151,11 +158,17 @@ fun AppBarContainer( } ) } - } else { - Box { + } + AnimatedVisibility( + visible = !useOldNavbar, + enter = enterAnimation, + exit = exitAnimation + ) { + Box(modifier = Modifier.fillMaxSize()) { content() - val showNavbar by remember(bottomBarState.value, isScrolling.value) { - mutableStateOf(bottomBarState.value && !isScrolling.value) + val hideNavBarSetting by rememberAutoHideNavBar() + val showNavbar by remember(bottomBarState, isScrolling, hideNavBarSetting) { + mutableStateOf(bottomBarState && (!isScrolling || !hideNavBarSetting)) } AnimatedVisibility( modifier = Modifier @@ -200,7 +213,7 @@ private fun navigate(navController: NavController, route: String) { @Composable fun GalleryNavBar( modifier: Modifier, - backStackEntry: State, + backStackEntry: NavBackStackEntry?, navigationItems: List, onClick: (route: String) -> Unit, ) { @@ -216,7 +229,9 @@ fun GalleryNavBar( verticalAlignment = Alignment.CenterVertically ) { navigationItems.forEach { item -> - val selected = item.route == backStackEntry.value?.destination?.route + val selected = remember(item, backStackEntry) { + item.route == backStackEntry?.destination?.route + } GalleryNavBarItem( navItem = item, isSelected = selected, @@ -226,30 +241,32 @@ fun GalleryNavBar( } } +@Stable +@Composable +private fun Label(item: NavigationItem) = Text( + text = item.name, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium, +) + +@Stable +@Composable +private fun Icon(item: NavigationItem) = Icon( + imageVector = item.icon, + contentDescription = item.name, +) + @Composable fun ClassicNavBar( - backStackEntry: State, + backStackEntry: NavBackStackEntry?, navigationItems: List, onClick: (route: String) -> Unit ) { - val label: @Composable (item: NavigationItem) -> Unit = { - Text( - text = it.name, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium, - ) - } - val icon: @Composable (item: NavigationItem) -> Unit = { - Icon( - imageVector = it.icon, - contentDescription = "${it.name} Icon", - ) - } NavigationBar( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) ) { navigationItems.forEach { item -> - val selected = item.route == backStackEntry.value?.destination?.route + val selected = item.route == backStackEntry?.destination?.route NavigationBarItem( selected = selected, colors = NavigationBarItemDefaults.colors( @@ -264,8 +281,8 @@ fun ClassicNavBar( onClick(item.route) } }, - label = { label(item) }, - icon = { icon(item) } + label = { Label(item) }, + icon = { Icon(item) } ) } } @@ -273,29 +290,16 @@ fun ClassicNavBar( @Composable private fun ClassicNavigationRail( - backStackEntry: State, + backStackEntry: NavBackStackEntry?, navigationItems: List, onClick: (route: String) -> Unit ) { - val label: @Composable (item: NavigationItem) -> Unit = { - Text( - text = it.name, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium, - ) - } - val icon: @Composable (item: NavigationItem) -> Unit = { - Icon( - imageVector = it.icon, - contentDescription = "${it.name} Icon", - ) - } NavigationRail( containerColor = MaterialTheme.colorScheme.surface ) { Spacer(Modifier.weight(1f)) navigationItems.forEach { item -> - val selected = item.route == backStackEntry.value?.destination?.route + val selected = item.route == backStackEntry?.destination?.route NavigationRailItem( selected = selected, colors = NavigationRailItemDefaults.colors( @@ -307,8 +311,8 @@ private fun ClassicNavigationRail( onClick(item.route) } }, - label = { label(item) }, - icon = { icon(item) } + label = { Label(item) }, + icon = { Label(item) } ) } Spacer(Modifier.weight(1f)) diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyMedia.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyMedia.kt index 0cdffc3286..2595111758 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyMedia.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyMedia.kt @@ -5,43 +5,33 @@ package com.dot.gallery.core.presentation.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.dot.gallery.R -import com.dot.gallery.ui.core.Icons -import com.dot.gallery.ui.core.icons.NoImage @Composable fun EmptyMedia( modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(16.dp), title: String = stringResource(R.string.no_media_title), -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - modifier = Modifier - .size(128.dp), - imageVector = Icons.NoImage, - contentDescription = stringResource(R.string.no_media_cd), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.size(16.dp)) +) = LoadingMedia( + modifier = modifier, + paddingValues = paddingValues, + shouldShimmer = false, + topContent = { Text( + modifier = Modifier.fillMaxWidth().padding(16.dp).padding(top = paddingValues.calculateTopPadding() / 2), text = title, - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center ) } -} \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt index aa2dc756a3..1d67cc77ca 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/FilterComponent.kt @@ -5,19 +5,23 @@ package com.dot.gallery.core.presentation.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.KeyboardDoubleArrowDown +import androidx.compose.material.icons.outlined.KeyboardDoubleArrowUp import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,9 +29,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.dot.gallery.R import com.dot.gallery.core.Settings.Album.rememberLastSort import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.domain.util.OrderType @@ -39,52 +44,73 @@ fun FilterButton( ) { var lastSort by rememberLastSort() var expanded by remember { mutableStateOf(false) } - var selectedFilter by remember(lastSort) { mutableStateOf(filterOptions.first { it.selected }.titleRes) } + val selectedFilter by remember(lastSort) { mutableStateOf(filterOptions.first { it.filterKind == lastSort.kind }) } + var order: OrderType by remember(lastSort) { mutableStateOf(lastSort.orderType) } Box( modifier = modifier .fillMaxWidth() - .wrapContentSize(Alignment.TopEnd) + .padding(top = 16.dp) .padding(horizontal = 8.dp), contentAlignment = Alignment.TopEnd ) { - TextButton(onClick = { expanded = true }) { - Row { - Text( - modifier = Modifier.padding(end = 4.dp), - text = stringResource(selectedFilter) - ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(100)) + .clickable { + expanded = true + } + .padding(vertical = 4.dp, horizontal = 8.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + text = stringResource(selectedFilter.titleRes) + ) + IconButton( + onClick = { + order = if (order == OrderType.Ascending) OrderType.Descending else OrderType.Ascending + lastSort = lastSort.copy(orderType = order) + } + ) { Icon( - imageVector = Icons.Outlined.FilterList, - contentDescription = stringResource(R.string.filter) + imageVector = remember(selectedFilter) { + if (order == OrderType.Descending) + Icons.Outlined.KeyboardDoubleArrowDown + else Icons.Outlined.KeyboardDoubleArrowUp + }, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null ) } } - DropdownMenu( + Box( modifier = Modifier - .align(Alignment.TopEnd), - expanded = expanded, - onDismissRequest = { expanded = false } + .wrapContentSize(Alignment.TopEnd) ) { - for (filter in filterOptions) { - DropdownMenuItem( - text = { Text(text = stringResource(filter.titleRes)) }, - onClick = { - filterOptions.forEach { - it.selected = it.titleRes == filter.titleRes - if (it.selected) selectedFilter = it.titleRes + DropdownMenu( + modifier = Modifier.align(Alignment.TopEnd), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + filterOptions.forEach { filter -> + DropdownMenuItem( + text = { Text(text = stringResource(filter.titleRes)) }, + onClick = { + lastSort = lastSort.copy(kind = filter.filterKind) + }, + trailingIcon = { + if (lastSort.kind == filter.filterKind) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null + ) + } } - filter.onClick(filter.mediaOrder) - lastSort = filterOptions.indexOf(filter) - }, - trailingIcon = { - if (filter.selected) - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = null - ) - } - ) + ) + } } } } @@ -94,6 +120,9 @@ fun FilterButton( data class FilterOption( val titleRes: Int = -1, val onClick: (MediaOrder) -> Unit = {}, - val mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending), - var selected: Boolean = false -) \ No newline at end of file + val filterKind: FilterKind = FilterKind.DATE +) + +enum class FilterKind { + DATE, NAME +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingMedia.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingMedia.kt index 30928a495e..e25bca479f 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingMedia.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingMedia.kt @@ -33,7 +33,9 @@ import com.valentinilk.shimmer.shimmer @Composable fun LoadingMedia( modifier: Modifier = Modifier, - paddingValues: PaddingValues + paddingValues: PaddingValues, + shouldShimmer: Boolean = true, + topContent: @Composable (() -> Unit)? = null, ) { val gridState = rememberLazyGridState() val gridSize by rememberGridSize() @@ -41,12 +43,17 @@ fun LoadingMedia( state = gridState, modifier = modifier .fillMaxSize() - .shimmer(), + .then(if (shouldShimmer) Modifier.shimmer() else Modifier), columns = cellsList[gridSize], contentPadding = paddingValues, horizontalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp), ) { + if (topContent != null) { + item(span = { GridItemSpan(maxLineSpan) }) { + topContent() + } + } item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier @@ -73,31 +80,33 @@ fun LoadingMedia( ) ) } - item(span = { GridItemSpan(maxLineSpan) }) { - Box( - modifier = Modifier - .padding(horizontal = 24.dp, vertical = 24.dp) - ) { - Spacer( + if (shouldShimmer) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box( modifier = Modifier - .height(24.dp) - .fillMaxWidth(0.35f) + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + Spacer( + modifier = Modifier + .height(24.dp) + .fillMaxWidth(0.35f) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(100) + ) + ) + } + } + items(count = 25) { + Box( + modifier = Modifier + .aspectRatio(1f) + .size(Dimens.Photo()) .background( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - shape = RoundedCornerShape(100) + color = MaterialTheme.colorScheme.surfaceVariant ) ) } } - items(count = 25) { - Box( - modifier = Modifier - .aspectRatio(1f) - .size(Dimens.Photo()) - .background( - color = MaterialTheme.colorScheme.surfaceVariant - ) - ) - } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt index f52e911e35..4ed237710e 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt @@ -6,10 +6,13 @@ package com.dot.gallery.core.presentation.components import android.os.Build +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,8 +30,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navArgument -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.setSingletonImageLoaderFactory import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.core.Constants.Animation.navigateInAnimation @@ -47,6 +48,7 @@ import com.dot.gallery.feature_node.presentation.common.ChanneledViewModel import com.dot.gallery.feature_node.presentation.common.MediaViewModel import com.dot.gallery.feature_node.presentation.favorites.FavoriteScreen import com.dot.gallery.feature_node.presentation.ignored.IgnoredScreen +import com.dot.gallery.feature_node.presentation.ignored.setup.IgnoredSetup import com.dot.gallery.feature_node.presentation.library.LibraryScreen import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreen import com.dot.gallery.feature_node.presentation.settings.SettingsScreen @@ -54,12 +56,12 @@ import com.dot.gallery.feature_node.presentation.setup.SetupScreen import com.dot.gallery.feature_node.presentation.timeline.TimelineScreen import com.dot.gallery.feature_node.presentation.trashed.TrashedGridScreen import com.dot.gallery.feature_node.presentation.util.Screen -import com.dot.gallery.feature_node.presentation.util.newImageLoader import com.dot.gallery.feature_node.presentation.vault.VaultScreen import com.dot.gallery.feature_node.presentation.vault.VaultViewModel import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.EncryptedMediaViewScreen -@OptIn(ExperimentalCoilApi::class) +@Stable +@NonRestartableComposable @Composable fun NavigationComp( navController: NavHostController, @@ -81,7 +83,7 @@ fun NavigationComp( val groupTimelineByMonth by rememberTimelineGroupByMonth() val context = LocalContext.current - var permissionState by remember { mutableStateOf(context.permissionGranted(Constants.PERMISSIONS)) } + var permissionState = remember { context.permissionGranted(Constants.PERMISSIONS) } var lastStartScreen by rememberLastScreen() val startDest = remember(permissionState, lastStartScreen) { if (permissionState) { @@ -92,7 +94,7 @@ fun NavigationComp( navController.currentDestination?.route ?: lastStartScreen } OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_PAUSE) { + if (event == Lifecycle.Event.ON_STOP) { if (currentDest == Screen.TimelineScreen() || currentDest == Screen.AlbumsScreen()) { lastStartScreen = currentDest } @@ -137,8 +139,6 @@ fun NavigationComp( timelineViewModel.groupByMonth = groupTimelineByMonth } - setSingletonImageLoaderFactory(::newImageLoader) - NavHost( navController = navController, startDestination = startDest, @@ -150,7 +150,9 @@ fun NavigationComp( composable( route = Screen.SetupScreen(), ) { - navPipe.toggleNavbar(false) + LaunchedEffect(Unit) { + navPipe.toggleNavbar(false) + } SetupScreen { permissionState = true navPipe.navigate(Screen.TimelineScreen()) @@ -291,18 +293,22 @@ fun NavigationComp( val albumId: Long = remember(backStackEntry) { backStackEntry.arguments?.getLong("albumId") ?: -1L } - if (albumId != -1L) { + AnimatedVisibility(albumId != -1L) { timelineViewModel.ObserveCustomMediaState { getMediaFromAlbum(albumId) } } + val mediaState by remember(albumId) { + if (albumId != -1L) timelineViewModel.customMediaState else timelineViewModel.mediaState + }.collectAsStateWithLifecycle() + val albumsState by albumsViewModel.albumsState.collectAsStateWithLifecycle() MediaViewScreen( navigateUp = navPipe::navigateUp, toggleRotate = toggleRotate, paddingValues = paddingValues, mediaId = mediaId, - mediaState = if (albumId != -1L) timelineViewModel.customMediaState else timelineViewModel.mediaState, - albumsState = albumsViewModel.albumsState, + mediaState = mediaState, + albumsState = albumsState, handler = timelineViewModel.handler, addMedia = timelineViewModel::addMedia, vaults = vaults @@ -338,23 +344,27 @@ fun NavigationComp( navController.getBackStackEntry(entryName) } val viewModel = if (target == TARGET_FAVORITES) { - timelineViewModel.also { - timelineViewModel.ObserveCustomMediaState(MediaViewModel::getFavoriteMedia) + timelineViewModel.apply { + ObserveCustomMediaState(MediaViewModel::getFavoriteMedia) } } else { - hiltViewModel(parentEntry).also { - it.attachToLifecycle() + hiltViewModel(parentEntry).apply { + attachToLifecycle() } } - val vaults by viewModel.vaults.collectAsStateWithLifecycle() + val mediaState by remember(target) { + if (target == TARGET_FAVORITES) viewModel.customMediaState else viewModel.mediaState + }.collectAsStateWithLifecycle() + val albumsState by albumsViewModel.albumsState.collectAsStateWithLifecycle() + MediaViewScreen( navigateUp = navPipe::navigateUp, toggleRotate = toggleRotate, paddingValues = paddingValues, mediaId = mediaId, target = target, - mediaState = if (target == TARGET_FAVORITES) viewModel.customMediaState else viewModel.mediaState, - albumsState = albumsViewModel.albumsState, + mediaState = mediaState, + albumsState = albumsState, handler = viewModel.handler, addMedia = viewModel::addMedia, vaults = vaults @@ -369,15 +379,26 @@ fun NavigationComp( ) } composable( - route = Screen.BlacklistScreen() + route = Screen.IgnoredScreen() ) { - val albumsState by albumsViewModel.albumsState.collectAsStateWithLifecycle() + val albumsState by albumsViewModel.unfilteredAlbums.collectAsStateWithLifecycle() IgnoredScreen( navigateUp = navPipe::navigateUp, + startSetup = { navPipe.navigate(Screen.IgnoredSetupScreen()) }, albumsState = albumsState ) } + composable( + route = Screen.IgnoredSetupScreen() + ) { + val albumsState by albumsViewModel.unfilteredAlbums.collectAsStateWithLifecycle() + IgnoredSetup( + onCancel = navPipe::navigateUp, + albumState = albumsState + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { composable( route = Screen.VaultScreen() @@ -402,8 +423,9 @@ fun NavigationComp( val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(Screen.VaultScreen()) } - val mediaId = + val mediaId = remember(backStackEntry) { backStackEntry.arguments?.getLong("mediaId") + } val vm = hiltViewModel(parentEntry) EncryptedMediaViewScreen( navigateUp = navPipe::navigateUp, diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt new file mode 100644 index 0000000000..1e7e1c737c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SetupWizard.kt @@ -0,0 +1,195 @@ +package com.dot.gallery.core.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.dot.gallery.ui.theme.GalleryTheme + +@Composable +fun SetupWizard( + modifier: Modifier = Modifier, + icon: ImageVector, + title: String, + subtitle: String, + content: @Composable () -> Unit, + bottomBar: @Composable () -> Unit +) { + Scaffold( + modifier = modifier.fillMaxSize(), + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface + ) + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + bottomBar() + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(top = 24.dp) + .verticalScroll(state = rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = icon.name, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Text( + text = buildAnnotatedString { + val headLineMedium = MaterialTheme.typography.headlineMedium.toSpanStyle() + val bodyLarge = MaterialTheme.typography.bodyLarge.toSpanStyle() + val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant + withStyle(style = ParagraphStyle(textAlign = TextAlign.Center)) { + withStyle( + style = headLineMedium + ) { + append(title) + } + appendLine() + withStyle( + style = bodyLarge + .copy(color = onSurfaceVariant) + ) { + append(subtitle) + } + } + } + ) + Column( + modifier = Modifier + .padding(horizontal = 32.dp) + .padding(bottom = 16.dp), + ) { + content() + } + } + } +} + +@Composable +fun SetupWizard( + modifier: Modifier = Modifier, + painter: Painter, + title: String, + subtitle: String, + content: @Composable () -> Unit, + bottomBar: @Composable () -> Unit +) { + Scaffold( + modifier = modifier.fillMaxSize(), + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface + ) + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + bottomBar() + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(top = 24.dp) + .verticalScroll(state = rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painter, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Text( + text = buildAnnotatedString { + val headLineMedium = MaterialTheme.typography.headlineMedium.toSpanStyle() + val bodyLarge = MaterialTheme.typography.bodyLarge.toSpanStyle() + val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant + withStyle(style = ParagraphStyle(textAlign = TextAlign.Center)) { + withStyle( + style = headLineMedium + ) { + append(title) + } + appendLine() + withStyle( + style = bodyLarge + .copy(color = onSurfaceVariant) + ) { + append(subtitle) + } + } + } + ) + Column( + modifier = Modifier + .padding(horizontal = 32.dp) + .padding(bottom = 16.dp), + ) { + content() + } + } + } +} + +@Preview +@Composable +private fun Preview() { + GalleryTheme { + SetupWizard( + icon = Icons.Outlined.Settings, + title = "Title", + subtitle = "Subtitle", + content = { + Text("Content") + }, + bottomBar = { + Text("Bottom bar") + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/OnLifecycleEvent.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/OnLifecycleEvent.kt index 683cf6d98f..55ccde6e77 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/OnLifecycleEvent.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/OnLifecycleEvent.kt @@ -3,10 +3,10 @@ package com.dot.gallery.core.presentation.components.util import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner @Composable fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt index dd2e6fb540..0b2bcad66f 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/StickyHeaderGrid.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.statusBars import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -21,7 +23,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp - +@Stable +@NonRestartableComposable @Composable fun StickyHeaderGrid( modifier: Modifier = Modifier, diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/SettingsExt.kt b/app/src/main/kotlin/com/dot/gallery/core/util/SettingsExt.kt index ca7568ac24..a5f981a54b 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/util/SettingsExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/util/SettingsExt.kt @@ -5,6 +5,7 @@ package com.dot.gallery.core.util +import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -17,6 +18,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.core.dataStore import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json @Composable fun rememberPreference( @@ -42,6 +45,36 @@ fun rememberPreference( } } + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} + +@Composable +inline fun rememberPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val state by remember { + context.dataStore.data + .map { it[key] ?: Json.encodeToString(defaultValue) } + }.collectAsStateWithLifecycle(initialValue = Json.encodeToString(defaultValue)) + + return remember(state) { + object : MutableState { + override var value: T + get() = Json.decodeFromString(state) + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = Json.encodeToString(value) + } + } + } + override fun component1() = value override fun component2(): (T) -> Unit = { value = it } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt index 5396c2bbea..119ccf2aaf 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/BlacklistDao.kt @@ -4,22 +4,22 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Query import androidx.room.Upsert -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import kotlinx.coroutines.flow.Flow @Dao interface BlacklistDao { @Query("SELECT * FROM blacklist") - fun getBlacklistedAlbums(): Flow> + fun getBlacklistedAlbums(): Flow> + + @Query("SELECT * FROM blacklist") + fun getBlacklistedAlbumsSync(): List @Upsert - suspend fun addBlacklistedAlbum(blacklistedAlbum: BlacklistedAlbum) + suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) @Delete - suspend fun removeBlacklistedAlbum(blacklistedAlbum: BlacklistedAlbum) - - @Query("SELECT EXISTS(SELECT * FROM blacklist WHERE id = :albumId)") - fun albumIsBlacklisted(albumId: Long): Boolean + suspend fun removeBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt index 27276c0c49..38a68b45e5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/InternalDatabase.kt @@ -8,17 +8,22 @@ package com.dot.gallery.feature_node.data.data_source import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import androidx.room.TypeConverters +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.PinnedAlbum +import com.dot.gallery.feature_node.domain.util.Converters @Database( - entities = [PinnedAlbum::class, BlacklistedAlbum::class], - version = 2, + entities = [PinnedAlbum::class, IgnoredAlbum::class], + version = 4, exportSchema = true, autoMigrations = [ - AutoMigration(from = 1, to = 2) + AutoMigration(from = 1, to = 2), + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4) ] ) +@TypeConverters(Converters::class) abstract class InternalDatabase: RoomDatabase() { abstract fun getPinnedDao(): PinnedDao diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaQuery.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaQuery.kt index c6af20713b..96cde8be75 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaQuery.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaQuery.kt @@ -24,6 +24,7 @@ sealed class Query( MediaStore.MediaColumns.DATE_TAKEN, MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.DURATION, + MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.IS_FAVORITE, MediaStore.MediaColumns.IS_TRASHED @@ -41,6 +42,7 @@ sealed class Query( MediaStore.MediaColumns.DATE_EXPIRES, MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.DURATION, + MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.IS_FAVORITE, MediaStore.MediaColumns.IS_TRASHED @@ -69,6 +71,7 @@ sealed class Query( MediaStore.MediaColumns.DATE_TAKEN, MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.DURATION, + MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.IS_FAVORITE, MediaStore.MediaColumns.IS_TRASHED @@ -95,6 +98,7 @@ sealed class Query( MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, MediaStore.MediaColumns.DURATION, + MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.ORIENTATION, MediaStore.MediaColumns.IS_FAVORITE, @@ -120,6 +124,7 @@ sealed class Query( MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.DATE_MODIFIED, MediaStore.MediaColumns.DATE_TAKEN diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetAlbums.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetAlbums.kt index 8eea462ace..e0c8029623 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetAlbums.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetAlbums.kt @@ -54,6 +54,8 @@ suspend fun ContentResolver.getAlbums( getString(getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)) val thumbnailDate = getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)) + val size = + getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)) val mimeType = getString(getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)) val contentUri = if (mimeType.contains("image")) @@ -67,6 +69,7 @@ suspend fun ContentResolver.getAlbums( pathToThumbnail = thumbnailPath, relativePath = thumbnailRelativePath, timestamp = thumbnailDate, + size = size, count = 1 ) val currentAlbum = albums.find { albm -> albm.id == albumId } @@ -75,6 +78,7 @@ suspend fun ContentResolver.getAlbums( else { val i = albums.indexOf(currentAlbum) albums[i].count++ + albums[i].size += size if (albums[i].timestamp <= thumbnailDate) { album.count = albums[i].count albums[i] = album diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt index d24adcf059..209abfd004 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt @@ -254,6 +254,8 @@ fun Cursor.getMediaFromCursor(): Media { } catch (_: Exception) { null } + val size: Long = + getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)) val mimeType: String = getString(getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)) val isFavorite: Int = @@ -286,6 +288,7 @@ fun Cursor.getMediaFromCursor(): Media { duration = duration, favorite = isFavorite, trashed = isTrashed, + size = size, mimeType = mimeType ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt index e83134f979..7ca16b8e16 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt @@ -20,6 +20,7 @@ import androidx.activity.result.IntentSenderRequest import androidx.core.app.ActivityOptionsCompat import com.dot.gallery.core.Resource import com.dot.gallery.core.contentFlowObserver +import com.dot.gallery.core.contentFlowWithDatabase import com.dot.gallery.core.fileFlowObserver import com.dot.gallery.feature_node.data.data_source.InternalDatabase import com.dot.gallery.feature_node.data.data_source.KeychainHolder @@ -38,9 +39,9 @@ import com.dot.gallery.feature_node.data.data_types.saveImage import com.dot.gallery.feature_node.data.data_types.updateMedia import com.dot.gallery.feature_node.data.data_types.updateMediaExif import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.ExifAttributes +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.PinnedAlbum import com.dot.gallery.feature_node.domain.model.Vault @@ -72,12 +73,12 @@ class MediaRepositoryImpl( * TODO: Add media reordering */ override fun getMedia(): Flow>> = - context.retrieveMedia { + context.retrieveMedia(database) { it.getMedia(mediaOrder = DEFAULT_ORDER).removeBlacklisted() } override fun getMediaByType(allowedMedia: AllowedMedia): Flow>> = - context.retrieveMedia { + context.retrieveMedia(database) { val query = when (allowedMedia) { PHOTOS -> Query.PhotoQuery() VIDEOS -> Query.VideoQuery() @@ -87,12 +88,12 @@ class MediaRepositoryImpl( } override fun getFavorites(mediaOrder: MediaOrder): Flow>> = - context.retrieveMedia { + context.retrieveMedia(database) { it.getMediaFavorite(mediaOrder = mediaOrder).removeBlacklisted() } override fun getTrashed(): Flow>> = - context.retrieveMedia { + context.retrieveMedia(database) { it.getMediaTrashed().removeBlacklisted() } @@ -100,17 +101,12 @@ class MediaRepositoryImpl( mediaOrder: MediaOrder, ignoreBlacklisted: Boolean ): Flow>> = - context.retrieveAlbums { - it.getAlbums(mediaOrder = mediaOrder).toMutableList().apply { + context.retrieveAlbums(database) { cr -> + cr.getAlbums(mediaOrder = mediaOrder).toMutableList().apply { replaceAll { album -> album.copy(isPinned = database.getPinnedDao().albumIsPinned(album.id)) } - if (!ignoreBlacklisted) { - removeAll { album -> - database.getBlacklistDao().albumIsBlacklisted(album.id) - } - } - } + }.removeBlacklisted(ignoreBlacklisted) } override suspend fun insertPinnedAlbum(pinnedAlbum: PinnedAlbum) = @@ -119,13 +115,13 @@ class MediaRepositoryImpl( override suspend fun removePinnedAlbum(pinnedAlbum: PinnedAlbum) = database.getPinnedDao().removePinnedAlbum(pinnedAlbum) - override suspend fun addBlacklistedAlbum(blacklistedAlbum: BlacklistedAlbum) = - database.getBlacklistDao().addBlacklistedAlbum(blacklistedAlbum) + override suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) = + database.getBlacklistDao().addBlacklistedAlbum(ignoredAlbum) - override suspend fun removeBlacklistedAlbum(blacklistedAlbum: BlacklistedAlbum) = - database.getBlacklistDao().removeBlacklistedAlbum(blacklistedAlbum) + override suspend fun removeBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) = + database.getBlacklistDao().removeBlacklistedAlbum(ignoredAlbum) - override fun getBlacklistedAlbums(): Flow> = + override fun getBlacklistedAlbums(): Flow> = database.getBlacklistDao().getBlacklistedAlbums() override suspend fun getMediaById(mediaId: Long): Media? { @@ -145,7 +141,7 @@ class MediaRepositoryImpl( } override fun getMediaByAlbumId(albumId: Long): Flow>> = - context.retrieveMedia { + context.retrieveMedia(database) { val query = Query.MediaQuery().copy( bundle = Bundle().apply { putString( @@ -166,7 +162,7 @@ class MediaRepositoryImpl( albumId: Long, allowedMedia: AllowedMedia ): Flow>> = - context.retrieveMedia { + context.retrieveMedia(database) { val query = Query.MediaQuery().copy( bundle = Bundle().apply { val mimeType = when (allowedMedia) { @@ -189,7 +185,7 @@ class MediaRepositoryImpl( } override fun getAlbumsWithType(allowedMedia: AllowedMedia): Flow>> = - context.retrieveAlbums { + context.retrieveAlbums(database) { val query = Query.AlbumQuery().copy( bundle = Bundle().apply { val mimeType = when (allowedMedia) { @@ -513,8 +509,14 @@ class MediaRepositoryImpl( } private fun List.removeBlacklisted(): List = toMutableList().apply { - removeAll { media -> - database.getBlacklistDao().albumIsBlacklisted(media.albumID) + val blacklistedAlbums = database.getBlacklistDao().getBlacklistedAlbumsSync() + removeAll { media -> blacklistedAlbums.any { it.matchesMedia(media) } } + } + + private fun MutableList.removeBlacklisted(ignoreBlacklisted: Boolean): List = apply { + if (!ignoreBlacklisted) { + val blacklistedAlbums = database.getBlacklistDao().getBlacklistedAlbumsSync() + removeAll { album -> blacklistedAlbums.any { it.matchesAlbum(album) } } } } @@ -542,8 +544,8 @@ class MediaRepositoryImpl( } }.conflate() - private fun Context.retrieveMedia(dataBody: suspend (ContentResolver) -> List) = - contentFlowObserver(URIs).map { + private fun Context.retrieveMedia(database: InternalDatabase, dataBody: suspend (ContentResolver) -> List) = + contentFlowWithDatabase(URIs, database).map { try { Resource.Success(data = dataBody.invoke(contentResolver)) } catch (e: Exception) { @@ -551,8 +553,8 @@ class MediaRepositoryImpl( } }.conflate() - private fun Context.retrieveAlbums(dataBody: suspend (ContentResolver) -> List) = - contentFlowObserver(URIs).map { + private fun Context.retrieveAlbums(database: InternalDatabase, dataBody: suspend (ContentResolver) -> List) = + contentFlowWithDatabase(URIs, database).map { try { Resource.Success(data = dataBody.invoke(contentResolver)) } catch (e: Exception) { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt index 116a6ab8cc..b1486343c5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Album.kt @@ -23,6 +23,7 @@ data class Album( val relativePath: String, val timestamp: Long, var count: Long = 0, + var size: Long = 0, val selected: Boolean = false, val isPinned: Boolean = false, ) : Parcelable { @@ -44,8 +45,7 @@ data class Album( uri = Uri.EMPTY, pathToThumbnail = "", relativePath = "", - timestamp = 0, - count = 0, + timestamp = 0 ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/BlacklistedAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/BlacklistedAlbum.kt deleted file mode 100644 index 2166253994..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/BlacklistedAlbum.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.dot.gallery.feature_node.domain.model - -import android.os.Parcelable -import androidx.compose.runtime.Immutable -import androidx.room.Entity -import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize - -@Entity(tableName = "blacklist") -@Parcelize -@Immutable -data class BlacklistedAlbum( - @PrimaryKey(autoGenerate = false) - val id: Long, - val label: String -): Parcelable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt new file mode 100644 index 0000000000..faf0d35056 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/IgnoredAlbum.kt @@ -0,0 +1,70 @@ +package com.dot.gallery.feature_node.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + +@Entity(tableName = "blacklist") +@Parcelize +@Immutable +data class IgnoredAlbum( + @PrimaryKey(autoGenerate = false) + val id: Long, + val label: String, + val wildcard: String? = null, + @ColumnInfo(defaultValue = ALBUMS_ONLY.toString()) + val location: Int = ALBUMS_ONLY, + @ColumnInfo(defaultValue = "[]") + val matchedAlbums: List = emptyList() +) : Parcelable { + + private val hiddenInBoth get() = location == ALBUMS_AND_TIMELINE + private val hiddenInAlbums get() = location == ALBUMS_ONLY || hiddenInBoth + private val hiddenInTimeline get() = location == TIMELINE_ONLY || hiddenInBoth + + fun matchesMedia(media: Media): Boolean = + matches( + id = media.albumID, + path = media.path, + relativePath = media.relativePath, + volume = media.volume, + shouldRemove = hiddenInTimeline + ) + + fun matchesAlbum(album: Album): Boolean = + matches( + id = album.id, + path = album.pathToThumbnail, + relativePath = album.relativePath, + volume = album.volume, + shouldRemove = hiddenInAlbums + ) + + private fun matches( + id: Long, + path: String, + relativePath: String, + volume: String, + shouldRemove: Boolean + ): Boolean { + val matchesId = this.id == id + if (matchesId) return shouldRemove + val regex = wildcard?.toRegex() + return regex?.let { + path.matches(it) || relativePath.matches(it) || volume.matches(it) + } ?: false + } + + companion object { + const val ALBUMS_ONLY = 0 + const val TIMELINE_ONLY = 1 + const val ALBUMS_AND_TIMELINE = 2 + } +} + +fun Regex.matchesAlbum(album: Album): Boolean { + return album.pathToThumbnail.matches(this) || album.relativePath.matches(this) || album.volume.matches(this) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt index 2255ee1ae6..e1b11720ff 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Media.kt @@ -12,7 +12,6 @@ import android.os.Parcelable import android.webkit.MimeTypeMap import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import coil3.compose.EqualityDelegate import com.dot.gallery.core.Constants import com.dot.gallery.feature_node.presentation.util.getDate import kotlinx.parcelize.IgnoredOnParcel @@ -37,6 +36,7 @@ data class Media( val mimeType: String, val favorite: Int, val trashed: Int, + val size: Long, val duration: String? = null, ) : Parcelable { @@ -165,21 +165,9 @@ data class Media( mimeType = mimeType, duration = duration, favorite = 0, + size = 0, trashed = 0 ) } } } - -/** - * Since the media object is stable, we can use a custom equality delegate - * to avoid the default equals and hashCode implementation and ensure that - * the object is always considered equal to another object of the same type - * regardless of its properties. - * This is avoiding unnecessary recompositions of the painter in the MediaImage - */ -class MediaEqualityDelegate : EqualityDelegate { - override fun equals(self: Any?, other: Any?): Boolean = true - - override fun hashCode(self: Any?): Int = 31 -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt index 2a68da67d6..d6b6c953a3 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt @@ -11,7 +11,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.ExifAttributes import com.dot.gallery.feature_node.domain.model.Media @@ -41,11 +41,11 @@ interface MediaRepository { suspend fun removePinnedAlbum(pinnedAlbum: PinnedAlbum) - suspend fun addBlacklistedAlbum(blacklistedAlbum: BlacklistedAlbum) + suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) - suspend fun removeBlacklistedAlbum(blacklistedAlbum: BlacklistedAlbum) + suspend fun removeBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) - fun getBlacklistedAlbums(): Flow> + fun getBlacklistedAlbums(): Flow> suspend fun getMediaById(mediaId: Long): Media? diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/BlacklistUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/BlacklistUseCase.kt index ad717a3a82..691463773a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/BlacklistUseCase.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/BlacklistUseCase.kt @@ -1,17 +1,17 @@ package com.dot.gallery.feature_node.domain.use_case -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.repository.MediaRepository class BlacklistUseCase( private val repository: MediaRepository ) { - suspend fun addToBlacklist(blacklistedAlbum: BlacklistedAlbum) = - repository.addBlacklistedAlbum(blacklistedAlbum) + suspend fun addToBlacklist(ignoredAlbum: IgnoredAlbum) = + repository.addBlacklistedAlbum(ignoredAlbum) - suspend fun removeFromBlacklist(blacklistedAlbum: BlacklistedAlbum) = - repository.removeBlacklistedAlbum(blacklistedAlbum) + suspend fun removeFromBlacklist(ignoredAlbum: IgnoredAlbum) = + repository.removeBlacklistedAlbum(ignoredAlbum) val blacklistedAlbums by lazy { repository.getBlacklistedAlbums() } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt new file mode 100644 index 0000000000..5df667ae1b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/Converters.kt @@ -0,0 +1,13 @@ +package com.dot.gallery.feature_node.domain.util + +import androidx.room.TypeConverter +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object Converters { + @TypeConverter + fun fromString(value: String?): List = Json.decodeFromString(value ?: "[]") + + @TypeConverter + fun fromList(list: List?): String = Json.encodeToString(list ?: emptyList()) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt index a4d8f667d0..ec29f5d74e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/MediaOrder.kt @@ -8,11 +8,20 @@ package com.dot.gallery.feature_node.domain.util import com.dot.gallery.feature_node.domain.model.Album import com.dot.gallery.feature_node.domain.model.Media -sealed class MediaOrder(private val orderType: OrderType) { +sealed class MediaOrder(val orderType: OrderType) { class Label(orderType: OrderType) : MediaOrder(orderType) class Date(orderType: OrderType) : MediaOrder(orderType) class Expiry(orderType: OrderType = OrderType.Descending): MediaOrder(orderType) + fun flipOrder(): MediaOrder { + return this.copy( + orderType = when (orderType) { + OrderType.Ascending -> OrderType.Descending + OrderType.Descending -> OrderType.Ascending + } + ) + } + fun copy(orderType: OrderType): MediaOrder { return when (this) { is Date -> Date(orderType) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt index 2d91708c3b..d59a69598f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/util/OrderType.kt @@ -5,7 +5,18 @@ package com.dot.gallery.feature_node.domain.util -sealed class OrderType { +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +sealed class OrderType : Parcelable { + @Serializable + @Parcelize data object Ascending : OrderType() + + @Serializable + @Parcelize data object Descending : OrderType() } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt index 2807b6b25d..85774c577f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -28,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dokar.pinchzoomgrid.PinchZoomGridLayout @@ -39,6 +41,8 @@ import com.dot.gallery.core.Settings.Album.rememberLastSort import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.Error import com.dot.gallery.core.presentation.components.FilterButton +import com.dot.gallery.core.presentation.components.FilterKind +import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent import com.dot.gallery.feature_node.presentation.albums.components.CarouselPinnedAlbums import com.dot.gallery.feature_node.presentation.common.MediaViewModel @@ -55,10 +59,10 @@ fun AlbumsScreen( isScrolling: MutableState, searchBarActive: MutableState ) { + val mediaState by mediaViewModel.mediaState.collectAsStateWithLifecycle() val state by viewModel.unPinnedAlbumsState.collectAsStateWithLifecycle() val pinnedState by viewModel.pinnedAlbumState.collectAsStateWithLifecycle() val filterOptions = viewModel.rememberFilters() - val albumSortSetting by rememberLastSort() var lastCellIndex by rememberAlbumGridSize() val pinchState = rememberPinchZoomGridState( @@ -70,9 +74,15 @@ fun AlbumsScreen( lastCellIndex = albumCellsList.indexOf(pinchState.currentCells) } - LaunchedEffect(state.albums, albumSortSetting) { - val filterOption = filterOptions.first { it.selected } - filterOption.onClick(filterOption.mediaOrder) + val lastSort by rememberLastSort() + LaunchedEffect(lastSort) { + val selectedFilter = filterOptions.first { it.filterKind == lastSort.kind } + selectedFilter.onClick( + when (selectedFilter.filterKind) { + FilterKind.DATE -> MediaOrder.Date(lastSort.orderType) + FilterKind.NAME -> MediaOrder.Label(lastSort.orderType) + } + ) } Scaffold( @@ -156,6 +166,25 @@ fun AlbumsScreen( onTogglePinClick = viewModel.onAlbumLongClick ) } + + if (state.albums.isNotEmpty()) { + item( + span = { GridItemSpan(maxLineSpan) }, + key = "albumDetails" + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .pinchItem(key = "albumDetails") + .padding(horizontal = 8.dp) + .padding(vertical = 24.dp), + text = stringResource(R.string.images_videos, mediaState.media.size), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } + } } } /** Error State Handling Block **/ diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt index 0a32630689..488e737e8a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsViewModel.kt @@ -17,6 +17,7 @@ import com.dot.gallery.R import com.dot.gallery.core.AlbumState import com.dot.gallery.core.Resource import com.dot.gallery.core.Settings +import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.presentation.components.FilterOption import com.dot.gallery.feature_node.domain.model.Album import com.dot.gallery.feature_node.domain.use_case.MediaUseCases @@ -39,6 +40,8 @@ class AlbumsViewModel @Inject constructor( private val _albumsState = MutableStateFlow(AlbumState()) val albumsState = _albumsState.asStateFlow() + private val _unfilteredAlbums = MutableStateFlow(AlbumState()) + val unfilteredAlbums = _unfilteredAlbums.asStateFlow() private val _unPinnedAlbumsState = MutableStateFlow(AlbumState()) val unPinnedAlbumsState = _unPinnedAlbumsState.asStateFlow() private val _pinnedAlbumState = MutableStateFlow(AlbumState()) @@ -67,29 +70,14 @@ class AlbumsViewModel @Inject constructor( return remember(lastValue) { mutableStateListOf( FilterOption( - titleRes = R.string.filter_recent, - mediaOrder = MediaOrder.Date(OrderType.Descending), - onClick = { updateOrder(it) }, - selected = lastValue == 0 + titleRes = R.string.filter_type_date, + filterKind = FilterKind.DATE, + onClick = { updateOrder(it) } ), FilterOption( - titleRes = R.string.filter_old, - mediaOrder = MediaOrder.Date(OrderType.Ascending), - onClick = { updateOrder(it) }, - selected = lastValue == 1 - ), - - FilterOption( - titleRes = R.string.filter_nameAZ, - mediaOrder = MediaOrder.Label(OrderType.Ascending), - onClick = { updateOrder(it) }, - selected = lastValue == 2 - ), - FilterOption( - titleRes = R.string.filter_nameZA, - mediaOrder = MediaOrder.Label(OrderType.Descending), - onClick = { updateOrder(it) }, - selected = lastValue == 3 + titleRes = R.string.filter_type_name, + filterKind = FilterKind.NAME, + onClick = { updateOrder(it) } ) ) } @@ -161,6 +149,21 @@ class AlbumsViewModel @Inject constructor( if (albumsState.value != newAlbumState) { _albumsState.emit(newAlbumState) } + if (unfilteredAlbums.value != newAlbumState) { + _unfilteredAlbums.emit(newAlbumState) + } + } + } + viewModelScope.launch(Dispatchers.IO) { + mediaUseCases.getAlbumsUseCase(mediaOrder, ignoreBlacklisted = true).collectLatest { result -> + val data = result.data ?: emptyList() + val error = + if (result is Resource.Error) result.message ?: "An error occurred" else "" + val newAlbumState = AlbumState(error = error, albums = data) + if (data == unfilteredAlbums.value.albums) return@collectLatest + if (unfilteredAlbums.value != newAlbumState) { + _unfilteredAlbums.emit(newAlbumState) + } } } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt index 9e86b0bf81..c3fe97e0eb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/AlbumComponent.kt @@ -7,7 +7,7 @@ package com.dot.gallery.feature_node.presentation.albums.components import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -23,7 +23,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material.icons.outlined.SdCard -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -46,21 +45,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.LocalPlatformContext -import coil3.compose.rememberAsyncImagePainter -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Scale import com.dot.gallery.R import com.dot.gallery.core.presentation.components.util.AutoResizeText import com.dot.gallery.core.presentation.components.util.FontSizeRange import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.MediaEqualityDelegate import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionSheet import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.formatSize import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.ui.theme.Shapes +import com.github.panpf.sketch.AsyncImage import kotlinx.coroutines.launch @Composable @@ -74,16 +69,6 @@ fun AlbumComponent( ) { val scope = rememberCoroutineScope() val appBottomSheetState = rememberAppBottomSheetState() - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(album.uri) - .memoryCachePolicy(CachePolicy.ENABLED) - .placeholderMemoryCacheKey(album.toString()) - .scale(Scale.FIT) - .build(), - modelEqualityDelegate = MediaEqualityDelegate(), - contentScale = ContentScale.FillBounds - ) Column( modifier = modifier .alpha(if (isEnabled) 1f else 0.4f) @@ -129,12 +114,12 @@ fun AlbumComponent( state = appBottomSheetState, optionList = arrayOf(optionList), headerContent = { - Image( + AsyncImage( modifier = Modifier .size(98.dp) .clip(Shapes.large), contentScale = ContentScale.Crop, - painter = painter, + uri = album.uri.toString(), contentDescription = album.label ) Text( @@ -220,7 +205,7 @@ fun AlbumComponent( id = R.plurals.item_count, count = album.count.toInt(), album.count - ), + ) + " (${formatSize(album.size)})", overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.labelMedium, @@ -264,7 +249,7 @@ fun AlbumImage( .combinedClickable( enabled = isEnabled, interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = { onItemClick(album) }, onLongClick = { onItemLongClick?.let { @@ -276,17 +261,7 @@ fun AlbumImage( .padding(48.dp) ) } else { - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(album.uri) - .memoryCachePolicy(CachePolicy.ENABLED) - .placeholderMemoryCacheKey(album.toString()) - .scale(Scale.FIT) - .build(), - modelEqualityDelegate = MediaEqualityDelegate(), - contentScale = ContentScale.FillBounds - ) - Image( + AsyncImage( modifier = Modifier .fillMaxSize() .border( @@ -298,7 +273,7 @@ fun AlbumImage( .combinedClickable( enabled = isEnabled, interactionSource = interactionSource, - indication = rememberRipple(), + indication = LocalIndication.current, onClick = { onItemClick(album) }, onLongClick = { onItemLongClick?.let { @@ -307,7 +282,7 @@ fun AlbumImage( } } ), - painter = painter, + uri = album.uri.toString(), contentDescription = album.label, contentScale = ContentScale.Crop, ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt index b7b9f07567..6aea765371 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/components/PinnedAlbumsCarousel.kt @@ -41,16 +41,15 @@ import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import coil3.load -import coil3.size.Scale -import coil3.compose.AsyncImage -import coil3.request.CachePolicy import com.dot.gallery.R import com.dot.gallery.feature_node.domain.model.Album import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionSheet import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.ui.theme.Shapes +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.loadImage +import com.github.panpf.sketch.resize.Scale import com.google.android.material.carousel.CarouselLayoutManager import com.google.android.material.carousel.MaskableFrameLayout import kotlinx.coroutines.launch @@ -137,7 +136,7 @@ fun CarouselPinnedAlbums( .size(98.dp) .clip(Shapes.large), contentScale = ContentScale.Crop, - model = currentAlbum!!.uri, + uri = currentAlbum!!.uri.toString(), contentDescription = currentAlbum!!.label ) Text( @@ -196,10 +195,8 @@ private class PinnedAlbumsAdapter( GradientDrawable.Orientation.BOTTOM_TOP, intArrayOf(containerColor, Color.TRANSPARENT) ) - albumImage.load(album.uri) { - scale(Scale.FIT) - memoryCachePolicy(CachePolicy.ENABLED) - placeholderMemoryCacheKey(album.toString()) + albumImage.loadImage(album.uri.toString()) { + scale(Scale.CENTER_CROP) } albumImage.isClickable = true albumImage.setOnClickListener { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt index 9f4bc68322..89133844f6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaScreen.kt @@ -75,7 +75,7 @@ fun MediaScreen( enableStickyHeaders: Boolean = true, allowNavBar: Boolean = false, navActionsContent: @Composable() (RowScope.(expandedDropDown: MutableState, result: ActivityResultLauncher) -> Unit), - emptyContent: @Composable () -> Unit, + emptyContent: @Composable (PaddingValues) -> Unit, aboveGridContent: @Composable() (() -> Unit)? = null, navigate: (route: String) -> Unit, navigateUp: () -> Unit, @@ -216,7 +216,10 @@ fun MediaScreen( } val showEmpty = remember(state) { state.media.isEmpty() && !state.isLoading && !showError } AnimatedVisibility(visible = showEmpty) { - emptyContent.invoke() + emptyContent.invoke(PaddingValues( + top = it.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp + )) } /** ************ **/ } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt index f744e996cf..4663f73caa 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGridView.kt @@ -8,7 +8,6 @@ package com.dot.gallery.feature_node.presentation.common.components import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +28,7 @@ import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -50,6 +50,7 @@ import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.MediaState +import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar import com.dot.gallery.core.presentation.components.StickyHeader import com.dot.gallery.core.presentation.components.util.StickyHeaderGrid import com.dot.gallery.feature_node.domain.model.Media @@ -61,7 +62,8 @@ import com.dot.gallery.feature_node.presentation.util.FeedbackManager import com.dot.gallery.feature_node.presentation.util.update import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Stable +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PinchZoomGridScope.MediaGridView( mediaState: MediaState, @@ -179,7 +181,7 @@ fun PinchZoomGridScope.MediaGridView( } else { MediaImage( modifier = Modifier - .animateItemPlacement() + .animateItem() .pinchItem(key = it.key), media = (item as MediaItem.MediaViewItem).media, selectionState = selectionState, @@ -207,7 +209,7 @@ fun PinchZoomGridScope.MediaGridView( ) { index, media -> MediaImage( modifier = Modifier - .animateItemPlacement() + .animateItem() .pinchItem(key = media.toString()), media = media, selectionState = selectionState, @@ -276,9 +278,11 @@ fun PinchZoomGridScope.MediaGridView( }.value } } + + val hideSearchBarSetting by rememberAutoHideSearchBar() val searchBarPadding by animateDpAsState( - targetValue = remember(isScrolling.value, showSearchBar, searchBarPaddingTop) { - if (showSearchBar && !isScrolling.value) { + targetValue = remember(isScrolling.value, showSearchBar, searchBarPaddingTop, hideSearchBarSetting) { + if (showSearchBar && (!isScrolling.value || !hideSearchBarSetting)) { SearchBarDefaults.InputFieldHeight + searchBarPaddingTop + 8.dp } else if (showSearchBar && isScrolling.value) searchBarPaddingTop else 0.dp }, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt index f5ed5d9103..c526933ac3 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaImage.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,20 +39,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import coil3.compose.LocalPlatformContext -import coil3.compose.rememberAsyncImagePainter -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Scale import com.dot.gallery.core.Constants.Animation import com.dot.gallery.core.presentation.components.CheckBox import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaEqualityDelegate import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoDurationHeader +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.request.ComposableImageRequest +import com.github.panpf.sketch.resize.Scale +@Stable @OptIn(ExperimentalFoundationApi::class) @Composable fun MediaImage( @@ -86,17 +84,6 @@ fun MediaImage( targetValue = if (isSelected) primaryContainerColor else Color.Transparent, label = "strokeColor" ) - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(media.uri) - .memoryCachePolicy(CachePolicy.ENABLED) - .placeholderMemoryCacheKey(media.toString()) - .scale(Scale.FIT) - .build(), - modelEqualityDelegate = MediaEqualityDelegate(), - contentScale = ContentScale.FillBounds, - filterQuality = FilterQuality.None - ) Box( modifier = modifier .combinedClickable( @@ -132,10 +119,12 @@ fun MediaImage( color = strokeColor ) ) { - Image( + AsyncImage( modifier = Modifier .fillMaxSize(), - painter = painter, + request = ComposableImageRequest(media.uri.toString()) { + scale(Scale.CENTER_CROP) + }, contentDescription = media.label, contentScale = ContentScale.Crop, ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/OptionSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/OptionSheet.kt index 1d9fb8d513..2e38b555c5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/OptionSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/OptionSheet.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding @@ -57,8 +56,7 @@ fun OptionSheet( }, containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, tonalElevation = 0.dp, - dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + dragHandle = { DragHandle() } ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt index c26e43068b..62c33333fd 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditActivity.kt @@ -6,27 +6,24 @@ import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.LaunchedEffect import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.setSingletonImageLoaderFactory -import com.dot.gallery.feature_node.presentation.util.newImageLoader import com.dot.gallery.ui.theme.GalleryTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class EditActivity : ComponentActivity() { - @OptIn(ExperimentalCoilApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() setContent { GalleryTheme( darkTheme = true ) { - setSingletonImageLoaderFactory(::newImageLoader) val viewModel = hiltViewModel() LaunchedEffect(intent.data) { intent.data?.let { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt index 89823085c7..ad3791d2e2 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditScreen.kt @@ -240,7 +240,7 @@ fun EditScreen( .wrapContentHeight() .animateContentSize(), userScrollEnabled = false, - beyondBoundsPageCount = 1, + beyondViewportPageCount = 1, verticalAlignment = Alignment.Bottom, state = pagerState ) { page -> diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt index f04d316f64..59f700bde3 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/EditViewModel.kt @@ -13,12 +13,8 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.core.graphics.drawable.toBitmapOrNull import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Size import com.dot.gallery.feature_node.domain.model.ImageFilter import com.dot.gallery.feature_node.domain.model.ImageModification import com.dot.gallery.feature_node.domain.model.Media @@ -26,7 +22,6 @@ import com.dot.gallery.feature_node.domain.use_case.MediaUseCases import com.dot.gallery.feature_node.presentation.edit.components.adjustments.Adjustment import com.dot.gallery.feature_node.presentation.edit.components.adjustments.AdjustmentFilter import com.dot.gallery.feature_node.presentation.util.flipHorizontally -import com.dot.gallery.feature_node.presentation.util.gpuImage import com.dot.gallery.feature_node.presentation.util.mapToImageFilters import com.dot.gallery.feature_node.presentation.util.rotate import dagger.hilt.android.lifecycle.HiltViewModel @@ -45,8 +40,6 @@ import javax.inject.Inject @HiltViewModel class EditViewModel @Inject constructor( - private val loader: ImageLoader, - private val request: ImageRequest.Builder, private val mediaUseCases: MediaUseCases, @ApplicationContext private val applicationContext: Context @@ -117,7 +110,7 @@ class EditViewModel @Inject constructor( _image.emit(null) origImage = null gpuImage = null - loader.execute( + /*loader.execute( request.data(uri) .apply { size(Size.ORIGINAL) } .target { drawable -> @@ -134,7 +127,7 @@ class EditViewModel @Inject constructor( } } .build() - ) + )*/ } } } @@ -149,7 +142,7 @@ class EditViewModel @Inject constructor( isRevertAction = true, updateFilters = modifications.lastOrNull()?.croppedImage != null ) - modifiedImages.removeLast() + modifiedImages.removeLastOrNull() } canRevert.value = modifiedImages.size > 0 } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/FilterSelector.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/FilterSelector.kt index 0402f3100d..4ebe2d552c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/FilterSelector.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/FilterSelector.kt @@ -2,6 +2,7 @@ package com.dot.gallery.feature_node.presentation.edit.components.filters import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -22,16 +23,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Scale +import androidx.core.graphics.drawable.toDrawable import com.dot.gallery.feature_node.domain.model.ImageFilter import com.dot.gallery.feature_node.presentation.edit.EditViewModel import com.dot.gallery.ui.theme.Shapes +import com.google.accompanist.drawablepainter.rememberDrawablePainter @Composable fun FilterSelector( @@ -79,7 +78,7 @@ fun FilterItem( targetValue = if (isSelected) MaterialTheme.colorScheme.tertiary else Color.Transparent, label = "colorAnimation" ) - AsyncImage( + Image( modifier = Modifier .size(92.dp) .clip(Shapes.large) @@ -92,11 +91,7 @@ fun FilterItem( enabled = !isSelected, onClick = onFilterSelect ), - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(imageFilter.filterPreview) - .memoryCachePolicy(CachePolicy.ENABLED) - .scale(Scale.FIT) - .build(), + painter = rememberDrawablePainter(imageFilter.filterPreview.toDrawable(LocalContext.current.resources)), contentScale = ContentScale.Crop, contentDescription = imageFilter.name ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/TextureComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/TextureComponent.kt new file mode 100644 index 0000000000..f776d831c0 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/filters/TextureComponent.kt @@ -0,0 +1,96 @@ +package com.dot.gallery.feature_node.presentation.edit.components.filters + +import android.media.effect.EffectFactory +import androidx.annotation.FloatRange +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.dot.gallery.feature_node.presentation.edit.components.utils.CustomEffect +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoFilter +import com.dot.gallery.feature_node.presentation.edit.components.views.ImageFilterView + +@Composable +fun TextureComponent( + filterState: FilterState, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var currentBitmap by remember(filterState) { mutableStateOf(filterState.bitmap) } + val filterView = remember(context) { ImageFilterView(context) } + + LaunchedEffect(filterState) { + filterState.setFilterView(filterView) + } + + LaunchedEffect(currentBitmap) { + if (currentBitmap != filterState.bitmap) { + currentBitmap = filterState.bitmap + filterView.setSourceBitmap(currentBitmap.asAndroidBitmap()) + } + } + + AndroidView( + modifier = modifier, + factory = { filterView } + ) +} + +fun brightnessEffect(@FloatRange(from = 0.0, to = 2.0) value: Float = 1.0f): CustomEffect { + return CustomEffect.Builder(EffectFactory.EFFECT_BRIGHTNESS) + .setParameter("brightness", value) + .build() +} + +fun contrastEffect(@FloatRange(from = 1.0, to = 2.0) value: Float = 1.0f): CustomEffect { + return CustomEffect.Builder(EffectFactory.EFFECT_CONTRAST) + .setParameter("contrast", value) + .build() +} + +fun temperatureEffect(@FloatRange(from = 0.0, to = 1.0) value: Float = 0.5f): CustomEffect { + return CustomEffect.Builder(EffectFactory.EFFECT_CONTRAST) + .setParameter("contrast", value) + .build() +} + +class FilterState(val bitmap: ImageBitmap) { + + private var filterView: ImageFilterView? = null + + var currentFilter: MutableState = mutableStateOf(filterView?.currentFilter ?: PhotoFilter.NONE) + set(value) { + filterView?.setFilterEffect(value.value) + _hasFilterApplied.value = filterView?.hasFilterApplied ?: false + field = value + } + + var currentCustomFilter: MutableState = mutableStateOf(filterView?.currentCustomFilter) + set(value) { + filterView?.setFilterEffect(value.value) + _hasFilterApplied.value = filterView?.hasFilterApplied ?: false + field = value + } + + private val _hasFilterApplied: MutableState = mutableStateOf(filterView?.hasFilterApplied ?: false) + val hasFilterApplied: State = _hasFilterApplied + + internal fun setFilterView(filterView: ImageFilterView) { + this.filterView = filterView + } + +} + +@Composable +fun rememberFilterState(key: Any? = Unit, sourceImage: ImageBitmap) = remember(key) { + FilterState(sourceImage) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BitmapUtil.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BitmapUtil.kt new file mode 100644 index 0000000000..6025662569 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BitmapUtil.kt @@ -0,0 +1,105 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Bitmap +import android.graphics.Color +import android.opengl.GLSurfaceView +import java.nio.IntBuffer +import javax.microedition.khronos.opengles.GL10 + +/** + * + * + * Bitmap utility class to perform different transformation on bitmap + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.2 + * @since 5/21/2018 + */ +internal object BitmapUtil { + /** + * Remove transparency in edited bitmap + * + * @param source edited image + * @return bitmap without any transparency + */ + fun removeTransparency(source: Bitmap): Bitmap { + var firstX = 0 + var firstY = 0 + var lastX = source.width + var lastY = source.height + val pixels = IntArray(source.width * source.height) + source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height) + loop@ for (x in 0 until source.width) { + for (y in 0 until source.height) { + if (pixels[x + y * source.width] != Color.TRANSPARENT) { + firstX = x + break@loop + } + } + } + loop@ for (y in 0 until source.height) { + for (x in firstX until source.width) { + if (pixels[x + y * source.width] != Color.TRANSPARENT) { + firstY = y + break@loop + } + } + } + loop@ for (x in source.width - 1 downTo firstX) { + for (y in source.height - 1 downTo firstY) { + if (pixels[x + y * source.width] != Color.TRANSPARENT) { + lastX = x + break@loop + } + } + } + loop@ for (y in source.height - 1 downTo firstY) { + for (x in source.width - 1 downTo firstX) { + if (pixels[x + y * source.width] != Color.TRANSPARENT) { + lastY = y + break@loop + } + } + } + return Bitmap.createBitmap(source, firstX, firstY, lastX - firstX, lastY - firstY) + } + + /** + * Save filter bitmap from [ImageFilterView] + * + * @param glSurfaceView surface view on which is image is drawn + * @param gl open gl source to read pixels from [GLSurfaceView] + * @return save bitmap + * @throws OutOfMemoryError error when system is out of memory to load and save bitmap + */ + @Throws(OutOfMemoryError::class) + fun createBitmapFromGLSurface(glSurfaceView: GLSurfaceView, gl: GL10): Bitmap { + val x = 0 + val y = 0 + val w = glSurfaceView.width + val h = glSurfaceView.height + val bitmapBuffer = IntArray(w * h) + val bitmapSource = IntArray(w * h) + val intBuffer = IntBuffer.wrap(bitmapBuffer) + intBuffer.position(0) + + gl.glReadPixels(x, y, w, h, GL10.GL_RGBA, GL10.GL_UNSIGNED_BYTE, intBuffer) + var offset1: Int + var offset2: Int + for (i in 0 until h) { + offset1 = i * w + offset2 = (h - i - 1) * w + for (j in 0 until w) { + val texturePixel = bitmapBuffer[offset1 + j] + val blue = texturePixel shr 16 and 0xff + val red = texturePixel shl 16 and 0x00ff0000 + val pixel = texturePixel and -0xff0100 or red or blue + bitmapSource[offset2 + j] = pixel + } + } + + return Bitmap.createBitmap(bitmapSource, w, h, Bitmap.Config.ARGB_8888) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BoxHelper.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BoxHelper.kt new file mode 100644 index 0000000000..b5288b909e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BoxHelper.kt @@ -0,0 +1,39 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView + +/** + * Created by Burhanuddin Rashid on 18/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal class BoxHelper( + private val mPhotoEditorView: PhotoEditorView, + private val mViewState: PhotoEditorViewState +) { + fun clearHelperBox() { + /*for (i in 0 until mPhotoEditorView.childCount) { + val childAt = mPhotoEditorView.getChildAt(i) + val frmBorder = childAt.findViewById(R.id.frmBorder) + frmBorder?.setBackgroundResource(0) + val imgClose = childAt.findViewById(R.id.imgPhotoEditorClose) + imgClose?.visibility = View.GONE + }*/ + mViewState.clearCurrentSelectedView() + } + + fun clearAllViews(drawingView: DrawingView?) { + for (i in 0 until mViewState.addedViewsCount) { + mPhotoEditorView.removeView(mViewState.getAddedView(i)) + } + drawingView?.let { + if (mViewState.containsAddedView(it)) { + mPhotoEditorView.addView(it) + } + } + + mViewState.clearAddedViews() + mViewState.clearRedoViews() + drawingView?.clearAll() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushDrawingStateListener.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushDrawingStateListener.kt new file mode 100644 index 0000000000..9be809c54d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushDrawingStateListener.kt @@ -0,0 +1,54 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView + +/** + * Created by Burhanuddin Rashid on 17/05/21. + * + * @author //github.com/burhanrashid52> + */ +class BrushDrawingStateListener internal constructor( + private val mPhotoEditorView: PhotoEditorView, + private val mViewState: PhotoEditorViewState +) : BrushViewChangeListener { + private var mOnPhotoEditorListener: OnPhotoEditorListener? = null + fun setOnPhotoEditorListener(onPhotoEditorListener: OnPhotoEditorListener?) { + mOnPhotoEditorListener = onPhotoEditorListener + } + + override fun onViewAdd(drawingView: DrawingView) { + if (mViewState.redoViewsCount > 0) { + mViewState.popRedoView() + } + mViewState.addAddedView(drawingView) + mOnPhotoEditorListener?.onAddViewListener( + ViewType.BRUSH_DRAWING, + mViewState.addedViewsCount + ) + } + + override fun onViewRemoved(drawingView: DrawingView) { + if (mViewState.addedViewsCount > 0) { + val removeView = mViewState.removeAddedView( + mViewState.addedViewsCount - 1 + ) + if (removeView !is DrawingView) { + mPhotoEditorView.removeView(removeView) + } + mViewState.pushRedoView(removeView) + } + mOnPhotoEditorListener?.onRemoveViewListener( + ViewType.BRUSH_DRAWING, + mViewState.addedViewsCount + ) + } + + override fun onStartDrawing() { + mOnPhotoEditorListener?.onStartViewChangeListener(ViewType.BRUSH_DRAWING) + + } + + override fun onStopDrawing() { + mOnPhotoEditorListener?.onStopViewChangeListener(ViewType.BRUSH_DRAWING) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushViewChangeListener.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushViewChangeListener.kt new file mode 100644 index 0000000000..36e62dcfe9 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/BrushViewChangeListener.kt @@ -0,0 +1,16 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView + +/** + * Created on 1/17/2018. + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * + * + */ +interface BrushViewChangeListener { + fun onViewAdd(drawingView: DrawingView) + fun onViewRemoved(drawingView: DrawingView) + fun onStartDrawing() + fun onStopDrawing() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/CustomEffect.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/CustomEffect.kt new file mode 100644 index 0000000000..c1fb6b722c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/CustomEffect.kt @@ -0,0 +1,64 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.text.TextUtils +import com.dot.gallery.feature_node.presentation.edit.components.utils.CustomEffect.Builder +import java.util.* + +/** + * Define your custom effect using [Builder] class + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.2 + * @since 5/22/2018 + */ +class CustomEffect private constructor(builder: Builder) { + /** + * @return Custom effect name from [android.media.effect.EffectFactory.createEffect] + */ + val effectName: String = builder.mEffectName + + /** + * @return map of key and value of parameters for [android.media.effect.Effect.setParameter] + */ + val parameters: Map = builder.parametersMap + + /** + * Set customize effect to image using this builder class + */ + class Builder(effectName: String) { + val mEffectName: String + val parametersMap: MutableMap = HashMap() + + /** + * set parameter to the attributes with its value + * + * @param paramKey attribute key for [android.media.effect.Effect.setParameter] + * @param paramValue value for [android.media.effect.Effect.setParameter] + * @return builder instance to setup multiple parameters + */ + fun setParameter(paramKey: String, paramValue: Any): Builder { + parametersMap[paramKey] = paramValue + return this + } + + /** + * @return instance for custom effect + */ + fun build(): CustomEffect { + return CustomEffect(this) + } + + /** + * Initiate your custom effect + * + * @param effectName custom effect name from [android.media.effect.EffectFactory.createEffect] + * @throws RuntimeException exception when effect name is empty + */ + init { + if (TextUtils.isEmpty(effectName)) { + throw RuntimeException("Effect name cannot be empty.Please provide effect name from EffectFactory") + } + mEffectName = effectName + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Emoji.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Emoji.kt new file mode 100644 index 0000000000..025fa1e7d8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Emoji.kt @@ -0,0 +1,58 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Typeface +import android.view.Gravity +import android.view.View +import android.widget.TextView +import com.dot.gallery.R + +/** + * Created by Burhanuddin Rashid on 14/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal class Emoji( + private val mPhotoEditorView: PhotoEditorView, + private val mMultiTouchListener: MultiTouchListener, + private val mViewState: PhotoEditorViewState, + graphicManager: GraphicManager?, + private val mDefaultEmojiTypeface: Typeface? +) : Graphic( + context = mPhotoEditorView.context, + graphicManager = graphicManager, + viewType = ViewType.EMOJI, + layoutId = R.layout.view_photo_editor_text +) { + private var txtEmoji: TextView? = null + fun buildView(emojiTypeface: Typeface?, emojiName: String?) { + txtEmoji?.apply { + if (emojiTypeface != null) { + typeface = emojiTypeface + } + textSize = 56f + text = emojiName + } + } + + private fun setupGesture() { + val onGestureControl = buildGestureController(mPhotoEditorView, mViewState) + mMultiTouchListener.setOnGestureControl(onGestureControl) + val rootView = rootView + rootView.setOnTouchListener(mMultiTouchListener) + } + + override fun setupView(rootView: View) { + txtEmoji = rootView.findViewById(R.id.tvPhotoEditorText) + txtEmoji?.run { + if (mDefaultEmojiTypeface != null) { + typeface = mDefaultEmojiTypeface + } + gravity = Gravity.CENTER + setLayerType(View.LAYER_TYPE_SOFTWARE, null) + } + } + + init { + setupGesture() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GLToolbox.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GLToolbox.kt new file mode 100644 index 0000000000..e864f719d7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GLToolbox.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.dot.gallery.feature_node.presentation.edit.components.utils + +import android.opengl.GLES20 + +internal object GLToolbox { + private fun loadShader(shaderType: Int, source: String): Int { + val shader = GLES20.glCreateShader(shaderType) + if (shader != 0) { + GLES20.glShaderSource(shader, source) + GLES20.glCompileShader(shader) + val compiled = IntArray(1) + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0) + if (compiled[0] == 0) { + val info = GLES20.glGetShaderInfoLog(shader) + GLES20.glDeleteShader(shader) + throw RuntimeException("Could not compile shader $shaderType:$info") + } + } + return shader + } + + @JvmStatic + fun createProgram(vertexSource: String, fragmentSource: String): Int { + val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource) + if (vertexShader == 0) { + return 0 + } + val pixelShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource) + if (pixelShader == 0) { + return 0 + } + val program = GLES20.glCreateProgram() + if (program != 0) { + GLES20.glAttachShader(program, vertexShader) + checkGlError("glAttachShader") + GLES20.glAttachShader(program, pixelShader) + checkGlError("glAttachShader") + GLES20.glLinkProgram(program) + val linkStatus = IntArray(1) + GLES20.glGetProgramiv( + program, GLES20.GL_LINK_STATUS, linkStatus, + 0 + ) + if (linkStatus[0] != GLES20.GL_TRUE) { + val info = GLES20.glGetProgramInfoLog(program) + GLES20.glDeleteProgram(program) + throw RuntimeException("Could not link program: $info") + } + } + return program + } + + @JvmStatic + fun checkGlError(op: String) { + val error = GLES20.glGetError() + if (error != GLES20.GL_NO_ERROR) { + throw RuntimeException("$op: glError $error") + } + } + + @JvmStatic + fun initTexParams() { + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR + ) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR + ) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, + GLES20.GL_CLAMP_TO_EDGE + ) + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, + GLES20.GL_CLAMP_TO_EDGE + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Graphic.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Graphic.kt new file mode 100644 index 0000000000..79bb875cc2 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Graphic.kt @@ -0,0 +1,76 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils; + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import com.dot.gallery.R +import com.dot.gallery.feature_node.presentation.edit.components.utils.MultiTouchListener.OnGestureControl +/** + * Created by Burhanuddin Rashid on 14/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal abstract class Graphic( + val context: Context, + val layoutId: Int, + val viewType: ViewType, + val graphicManager: GraphicManager?) { + + val rootView: View + + open fun updateView(view: View) { + //Optional for subclass to override + } + + init { + if (layoutId == 0) { + throw UnsupportedOperationException("Layout id cannot be zero. Please define a layout") + } + rootView = LayoutInflater.from(context).inflate(layoutId, null) + setupView(rootView) + setupRemoveView(rootView) + } + + + private fun setupRemoveView(rootView: View) { + //We are setting tag as ViewType to identify what type of the view it is + //when we remove the view from stack i.e onRemoveViewListener(ViewType viewType, int numberOfAddedViews); + rootView.tag = viewType + val imgClose = rootView.findViewById(R.id.imgPhotoEditorClose) + imgClose?.setOnClickListener { graphicManager?.removeView(this@Graphic) } + } + + protected fun toggleSelection() { + val frmBorder = rootView.findViewById(R.id.frmBorder) + val imgClose = rootView.findViewById(R.id.imgPhotoEditorClose) + if (frmBorder != null) { + frmBorder.setBackgroundResource(R.drawable.rounded_border_tv) + frmBorder.tag = true + } + if (imgClose != null) { + imgClose.visibility = View.VISIBLE + } + } + + protected fun buildGestureController( + photoEditorView: PhotoEditorView, + viewState: PhotoEditorViewState + ): OnGestureControl { + val boxHelper = BoxHelper(photoEditorView, viewState) + return object : OnGestureControl { + override fun onClick() { + boxHelper.clearHelperBox() + toggleSelection() + // Change the in-focus view + viewState.currentSelectedView = rootView + } + + override fun onLongClick() { + updateView(rootView) + } + } + } + + open fun setupView(rootView: View) {} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GraphicManager.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GraphicManager.kt new file mode 100644 index 0000000000..50bed6a702 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/GraphicManager.kt @@ -0,0 +1,99 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.view.View +import android.view.ViewGroup +import android.widget.RelativeLayout +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView + +/** + * Created by Burhanuddin Rashid on 15/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal class GraphicManager( + private val mPhotoEditorView: PhotoEditorView, + private val mViewState: PhotoEditorViewState +) { + var onPhotoEditorListener: OnPhotoEditorListener? = null + fun addView(graphic: Graphic) { + val view = graphic.rootView + val params = RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE) + mPhotoEditorView.addView(view, params) + mViewState.addAddedView(view) + + if(mViewState.redoViewsCount > 0) { + mViewState.clearRedoViews() + } + + onPhotoEditorListener?.onAddViewListener( + graphic.viewType, + mViewState.addedViewsCount + ) + } + + fun removeView(graphic: Graphic) { + val view = graphic.rootView + if (mViewState.containsAddedView(view)) { + mPhotoEditorView.removeView(view) + mViewState.removeAddedView(view) + mViewState.pushRedoView(view) + onPhotoEditorListener?.onRemoveViewListener( + graphic.viewType, + mViewState.addedViewsCount + ) + } + } + + fun updateView(view: View) { + mPhotoEditorView.updateViewLayout(view, view.layoutParams) + mViewState.replaceAddedView(view) + } + + fun undoView(): Boolean { + if (mViewState.addedViewsCount > 0) { + val removeView = mViewState.getAddedView( + mViewState.addedViewsCount - 1 + ) + if (removeView is DrawingView) { + return removeView.undo() || (mViewState.addedViewsCount != 0) + } else { + mViewState.removeAddedView(mViewState.addedViewsCount - 1) + mPhotoEditorView.removeView(removeView) + mViewState.pushRedoView(removeView) + } + when (val viewTag = removeView.tag) { + is ViewType -> onPhotoEditorListener?.onRemoveViewListener( + viewTag, + mViewState.addedViewsCount + ) + } + } + return mViewState.addedViewsCount != 0 + } + + fun redoView(): Boolean { + if (mViewState.redoViewsCount > 0) { + val redoView = mViewState.getRedoView( + mViewState.redoViewsCount - 1 + ) + if (redoView is DrawingView) { + val result = redoView.redo() + return result || (mViewState.redoViewsCount != 0) + } else { + mViewState.popRedoView() + mPhotoEditorView.addView(redoView) + mViewState.addAddedView(redoView) + } + when (val viewTag = redoView.tag) { + is ViewType -> onPhotoEditorListener?.onAddViewListener( + viewTag, + mViewState.addedViewsCount + ) + } + } + return mViewState.redoViewsCount != 0 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/MultiTouchListener.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/MultiTouchListener.kt new file mode 100644 index 0000000000..0dd3c01464 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/MultiTouchListener.kt @@ -0,0 +1,267 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Rect +import android.view.GestureDetector +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.widget.ImageView +import kotlin.math.max +import kotlin.math.min + +/** + * Created on 18/01/2017. + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * + * + */ +internal class MultiTouchListener( + deleteView: View?, + photoEditorView: PhotoEditorView, + photoEditImageView: ImageView?, + private val mIsPinchScalable: Boolean, + onPhotoEditorListener: OnPhotoEditorListener?, + viewState: PhotoEditorViewState +) : OnTouchListener { + private val mGestureListener: GestureDetector + private val isRotateEnabled = true + private val isTranslateEnabled = true + private val isScaleEnabled = true + private val minimumScale = 0.5f + private val maximumScale = 10.0f + private var mActivePointerId = INVALID_POINTER_ID + private var mPrevX = 0f + private var mPrevY = 0f + private var mPrevRawX = 0f + private var mPrevRawY = 0f + private val mScaleGestureDetector: ScaleGestureDetector + private val location = IntArray(2) + private var outRect: Rect? = null + private val deleteView: View? + private val photoEditImageView: ImageView? + private val photoEditorView: PhotoEditorView + private var onMultiTouchListener: OnMultiTouchListener? = null + private var mOnGestureControl: OnGestureControl? = null + private val mOnPhotoEditorListener: OnPhotoEditorListener? + private val viewState: PhotoEditorViewState + override fun onTouch(view: View, event: MotionEvent): Boolean { + mScaleGestureDetector.onTouchEvent(view, event) + mGestureListener.onTouchEvent(event) + if (!isTranslateEnabled) { + return true + } + val action = event.action + val x = event.rawX.toInt() + val y = event.rawY.toInt() + when (action and event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + mPrevX = event.x + mPrevY = event.y + mPrevRawX = event.rawX + mPrevRawY = event.rawY + mActivePointerId = event.getPointerId(0) + if (deleteView != null) { + deleteView.visibility = View.VISIBLE + } + view.bringToFront() + firePhotoEditorSDKListener(view, true) + } + MotionEvent.ACTION_MOVE -> + // Only enable dragging on focused stickers. + if (view === viewState.currentSelectedView) { + val pointerIndexMove = event.findPointerIndex(mActivePointerId) + if (pointerIndexMove != -1) { + val currX = event.getX(pointerIndexMove) + val currY = event.getY(pointerIndexMove) + if (!mScaleGestureDetector.isInProgress) { + adjustTranslation(view, currX - mPrevX, currY - mPrevY) + } + } + } + MotionEvent.ACTION_CANCEL -> mActivePointerId = INVALID_POINTER_ID + MotionEvent.ACTION_UP -> { + mActivePointerId = INVALID_POINTER_ID + if (deleteView != null && isViewInBounds(deleteView, x, y)) { + onMultiTouchListener?.onRemoveViewListener(view) + } else if (!isViewInBounds(photoEditImageView, x, y)) { + view.animate().translationY(0f).translationY(0f) + } + if (deleteView != null) { + deleteView.visibility = View.GONE + } + firePhotoEditorSDKListener(view, false) + } + MotionEvent.ACTION_POINTER_UP -> { + val pointerIndexPointerUp = + action and MotionEvent.ACTION_POINTER_INDEX_MASK shr MotionEvent.ACTION_POINTER_INDEX_SHIFT + val pointerId = event.getPointerId(pointerIndexPointerUp) + if (pointerId == mActivePointerId) { + val newPointerIndex = if (pointerIndexPointerUp == 0) 1 else 0 + mPrevX = event.getX(newPointerIndex) + mPrevY = event.getY(newPointerIndex) + mActivePointerId = event.getPointerId(newPointerIndex) + } + } + } + return true + } + + private fun firePhotoEditorSDKListener(view: View, isStart: Boolean) { + val viewTag = view.tag + if (mOnPhotoEditorListener != null && viewTag != null && viewTag is ViewType) { + if (isStart) mOnPhotoEditorListener.onStartViewChangeListener(view.tag as ViewType) else mOnPhotoEditorListener.onStopViewChangeListener( + view.tag as ViewType + ) + } + } + + private fun isViewInBounds(view: View?, x: Int, y: Int): Boolean { + return view?.run { + getDrawingRect(outRect) + getLocationOnScreen(location) + outRect?.offset(location[0], location[1]) + outRect?.contains(x, y) + } ?: false + } + + fun setOnMultiTouchListener(onMultiTouchListener: OnMultiTouchListener?) { + this.onMultiTouchListener = onMultiTouchListener + } + + private inner class ScaleGestureListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { + private var mPivotX = 0f + private var mPivotY = 0f + private val mPrevSpanVector = Vector2D() + + override fun onScaleBegin(view: View, detector: ScaleGestureDetector): Boolean { + mPivotX = detector.getFocusX() + mPivotY = detector.getFocusY() + mPrevSpanVector.set(detector.getCurrentSpanVector()) + return mIsPinchScalable + } + + override fun onScale(view: View, detector: ScaleGestureDetector): Boolean { + val info = TransformInfo() + info.deltaScale = if (isScaleEnabled) detector.getScaleFactor() else 1.0f + info.deltaAngle = if (isRotateEnabled) Vector2D.getAngle( + mPrevSpanVector, + detector.getCurrentSpanVector() + ) else 0.0f + info.deltaX = if (isTranslateEnabled) detector.getFocusX() - mPivotX else 0.0f + info.deltaY = if (isTranslateEnabled) detector.getFocusY() - mPivotY else 0.0f + info.pivotX = mPivotX + info.pivotY = mPivotY + info.minimumScale = minimumScale + info.maximumScale = maximumScale + move(view, info) + return !mIsPinchScalable + } + } + + private inner class TransformInfo { + var deltaX = 0f + var deltaY = 0f + var deltaScale = 0f + var deltaAngle = 0f + var pivotX = 0f + var pivotY = 0f + var minimumScale = 0f + var maximumScale = 0f + } + + internal interface OnMultiTouchListener { + fun onEditTextClickListener(text: String, colorCode: Int) + fun onRemoveViewListener(removedView: View) + } + + internal interface OnGestureControl { + fun onClick() + fun onLongClick() + } + + fun setOnGestureControl(onGestureControl: OnGestureControl?) { + mOnGestureControl = onGestureControl + } + + private inner class GestureListener : SimpleOnGestureListener() { + override fun onSingleTapUp(e: MotionEvent): Boolean { + mOnGestureControl?.onClick() + + return true + } + + override fun onLongPress(e: MotionEvent) { + super.onLongPress(e) + mOnGestureControl?.onLongClick() + } + } + + companion object { + private const val INVALID_POINTER_ID = -1 + private fun adjustAngle(degrees: Float): Float { + return when { + degrees > 180.0f -> { + degrees - 360.0f + } + degrees < -180.0f -> { + degrees + 360.0f + } + else -> degrees + } + } + + private fun move(view: View, info: TransformInfo) { + computeRenderOffset(view, info.pivotX, info.pivotY) + adjustTranslation(view, info.deltaX, info.deltaY) + var scale = view.scaleX * info.deltaScale + scale = max(info.minimumScale, min(info.maximumScale, scale)) + view.scaleX = scale + view.scaleY = scale + val rotation = adjustAngle(view.rotation + info.deltaAngle) + view.rotation = rotation + } + + private fun adjustTranslation(view: View, deltaX: Float, deltaY: Float) { + val deltaVector = floatArrayOf(deltaX, deltaY) + view.matrix.mapVectors(deltaVector) + view.translationX = view.translationX + deltaVector[0] + view.translationY = view.translationY + deltaVector[1] + } + + private fun computeRenderOffset(view: View, pivotX: Float, pivotY: Float) { + if (view.pivotX == pivotX && view.pivotY == pivotY) { + return + } + val prevPoint = floatArrayOf(0.0f, 0.0f) + view.matrix.mapPoints(prevPoint) + view.pivotX = pivotX + view.pivotY = pivotY + val currPoint = floatArrayOf(0.0f, 0.0f) + view.matrix.mapPoints(currPoint) + val offsetX = currPoint[0] - prevPoint[0] + val offsetY = currPoint[1] - prevPoint[1] + view.translationX = view.translationX - offsetX + view.translationY = view.translationY - offsetY + } + } + + init { + mScaleGestureDetector = ScaleGestureDetector(ScaleGestureListener()) + mGestureListener = GestureDetector(deleteView?.context, GestureListener()) + this.deleteView = deleteView + this.photoEditorView = photoEditorView + this.photoEditImageView = photoEditImageView + mOnPhotoEditorListener = onPhotoEditorListener + outRect = if (deleteView != null) { + Rect( + deleteView.left, deleteView.top, + deleteView.right, deleteView.bottom + ) + } else { + Rect(0, 0, 0, 0) + } + this.viewState = viewState + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnPhotoEditorListener.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnPhotoEditorListener.kt new file mode 100644 index 0000000000..52ebf0262d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnPhotoEditorListener.kt @@ -0,0 +1,68 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.view.MotionEvent +import android.view.View + +/** + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.1 + * @since 18/01/2017 + * + * + * This are the callbacks when any changes happens while editing the photo to make and custimization + * on client side + * + */ +interface OnPhotoEditorListener { + /** + * When user long press the existing text this event will trigger implying that user want to + * edit the current [android.widget.TextView] + * + * @param rootView view on which the long press occurs + * @param text current text set on the view + * @param colorCode current color value set on view + */ + fun onEditTextChangeListener(rootView: View, text: String, colorCode: Int) + + /** + * This is a callback when user adds any view on the [PhotoEditorView] it can be + * brush,text or sticker i.e bitmap on parent view + * + * @param viewType enum which define type of view is added + * @param numberOfAddedViews number of views currently added + * @see ViewType + */ + fun onAddViewListener(viewType: ViewType, numberOfAddedViews: Int) + + /** + * This is a callback when user remove any view on the [PhotoEditorView] it happens when usually + * undo and redo happens or text is removed + * + * @param viewType enum which define type of view is added + * @param numberOfAddedViews number of views currently added + */ + fun onRemoveViewListener(viewType: ViewType, numberOfAddedViews: Int) + + /** + * A callback when user start dragging a view which can be + * any of [ViewType] + * + * @param viewType enum which define type of view is added + */ + fun onStartViewChangeListener(viewType: ViewType) + + /** + * A callback when user stop/up touching a view which can be + * any of [ViewType] + * + * @param viewType enum which define type of view is added + */ + fun onStopViewChangeListener(viewType: ViewType) + + /** + * A callback when the user touches the screen. + * + * @param event the MotionEvent associated to the touch. + */ + fun onTouchSourceImage(event: MotionEvent) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnSaveBitmap.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnSaveBitmap.kt new file mode 100644 index 0000000000..1f5555e725 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/OnSaveBitmap.kt @@ -0,0 +1,12 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Bitmap + +/** + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.2 + * @since 5/21/2018 + */ +interface OnSaveBitmap { + fun onBitmapReady(saveBitmap: Bitmap) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditor.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditor.kt new file mode 100644 index 0000000000..fc37167aa7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditor.kt @@ -0,0 +1,374 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Typeface +import android.view.View +import android.widget.ImageView +import androidx.annotation.IntRange +import androidx.annotation.RequiresPermission +import androidx.annotation.UiThread +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.ShapeBuilder +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView +import com.dot.gallery.feature_node.presentation.edit.components.views.PhotoEditorImpl + +/** + * Created by Burhanuddin Rashid on 14/05/21. + * + * @author //github.com/burhanrashid52> + */ +interface PhotoEditor { + /** + * This will add image on [PhotoEditorView] which you drag,rotate and scale using pinch + * if [PhotoEditor.Builder.setPinchTextScalable] enabled + * + * @param desiredImage bitmap image you want to add + */ + fun addImage(desiredImage: Bitmap) + + /** + * This add the text on the [PhotoEditorView] with provided parameters + * by default [TextView.setText] will be 18sp + * + * @param text text to display + * @param colorCodeTextView text color to be displayed + */ + @SuppressLint("ClickableViewAccessibility") + fun addText(text: String, colorCodeTextView: Int) + + /** + * This add the text on the [PhotoEditorView] with provided parameters + * by default [TextView.setText] will be 18sp + * + * @param textTypeface typeface for custom font in the text + * @param text text to display + * @param colorCodeTextView text color to be displayed + */ + @SuppressLint("ClickableViewAccessibility") + fun addText(textTypeface: Typeface?, text: String, colorCodeTextView: Int) + + /** + * This add the text on the [PhotoEditorView] with provided parameters + * by default [TextView.setText] will be 18sp + * + * @param text text to display + * @param styleBuilder text style builder with your style + */ + @SuppressLint("ClickableViewAccessibility") + fun addText(text: String, styleBuilder: TextStyleBuilder?) + + /** + * This will update text and color on provided view + * + * @param view view on which you want update + * @param inputText text to update [TextView] + * @param colorCode color to update on [TextView] + */ + fun editText(view: View, inputText: String, colorCode: Int) + + /** + * This will update the text and color on provided view + * + * @param view root view where text view is a child + * @param textTypeface update typeface for custom font in the text + * @param inputText text to update [TextView] + * @param colorCode color to update on [TextView] + */ + fun editText(view: View, textTypeface: Typeface?, inputText: String, colorCode: Int) + + /** + * This will update the text and color on provided view + * + * @param view root view where text view is a child + * @param inputText text to update [TextView] + * @param styleBuilder style to apply on [TextView] + */ + fun editText(view: View, inputText: String, styleBuilder: TextStyleBuilder?) + + /** + * Adds emoji to the [PhotoEditorView] which you drag,rotate and scale using pinch + * if [PhotoEditorImpl.Builder.setPinchTextScalable] enabled + * + * @param emojiName unicode in form of string to display emoji + */ + fun addEmoji(emojiName: String) + + /** + * Adds emoji to the [PhotoEditorView] which you drag,rotate and scale using pinch + * if [PhotoEditorImpl.Builder.setPinchTextScalable] enabled + * + * @param emojiTypeface typeface for custom font to show emoji unicode in specific font + * @param emojiName unicode in form of string to display emoji + */ + fun addEmoji(emojiTypeface: Typeface?, emojiName: String) + + /** + * Enable/Disable drawing mode to draw on [PhotoEditorView] + * + * @param brushDrawingMode true if mode is enabled + */ + fun setBrushDrawingMode(brushDrawingMode: Boolean) + + /** + * @return true is brush mode is enabled + */ + val brushDrawableMode: Boolean? + + /** + * set opacity/transparency of brush while painting on [DrawingView] + * @param opacity opacity is in form of percentage + */ + @Deprecated( + """use {@code setShape} of a ShapeBuilder + + """ + ) + fun setOpacity(@IntRange(from = 0, to = 100) opacity: Int) + + /** + * set the eraser size + * **Note :** Eraser size is different from the normal brush size + * + * @param brushEraserSize size of eraser + */ + fun setBrushEraserSize(brushEraserSize: Float) + + /** + * @return provide the size of eraser + * @see PhotoEditor.setBrushEraserSize + */ + val eraserSize: Float + /** + * @return provide the size of eraser + * @see PhotoEditor.setBrushSize + */ + /** + * Set the size of brush user want to paint on canvas i.e [DrawingView] + * @param size size of brush + */ + @set:Deprecated( + """use {@code setShape} of a ShapeBuilder + + """ + ) + var brushSize: Float + /** + * @return provide the size of eraser + * @see PhotoEditor.setBrushColor + */ + /** + * set brush color which user want to paint + * @param color color value for paint + */ + @set:Deprecated( + """use {@code setShape} of a ShapeBuilder + + """ + ) + var brushColor: Int + + /** + * + * + * Its enables eraser mode after that whenever user drags on screen this will erase the existing + * paint + *

+ * **Note** : This eraser will work on paint views only + * + * + */ + fun brushEraser() + + /** + * Undo the last operation perform on the [PhotoEditor] + * + * @return true if there nothing more to undo + */ + fun undo(): Boolean + + /** + * Redo the last operation perform on the [PhotoEditor] + * + * @return true if there nothing more to redo + */ + fun redo(): Boolean + + /** + * Removes all the edited operations performed [PhotoEditorView] + * This will also clear the undo and redo stack + */ + fun clearAllViews() + + /** + * Remove all helper boxes from views + */ + @UiThread + fun clearHelperBox() + + /** + * Setup of custom effect using effect type and set parameters values + * + * @param customEffect [CustomEffect.Builder.setParameter] + */ + fun setFilterEffect(customEffect: CustomEffect?) + + /** + * Set pre-define filter available + * + * @param filterType type of filter want to apply [PhotoEditorImpl] + */ + fun setFilterEffect(filterType: PhotoFilter) + + /** + * Save the edited image on given path + * + * @param imagePath path on which image to be saved + * @param saveSettings builder for multiple save options [SaveSettings] + */ + @RequiresPermission(allOf = [Manifest.permission.WRITE_EXTERNAL_STORAGE]) + suspend fun saveAsFile( + imagePath: String, + saveSettings: SaveSettings = SaveSettings.Builder().build() + ): SaveFileResult + + /** + * Save the edited image as bitmap + * + * @param saveSettings builder for multiple save options [SaveSettings] + */ + suspend fun saveAsBitmap(saveSettings: SaveSettings = SaveSettings.Builder().build()): Bitmap + + fun saveAsFile(imagePath: String, saveSettings: SaveSettings, onSaveListener: OnSaveListener) + + fun saveAsFile(imagePath: String, onSaveListener: OnSaveListener) + + fun saveAsBitmap(saveSettings: SaveSettings, onSaveBitmap: OnSaveBitmap) + + fun saveAsBitmap(onSaveBitmap: OnSaveBitmap) + + /** + * Callback on editing operation perform on [PhotoEditorView] + * + * @param onPhotoEditorListener [OnPhotoEditorListener] + */ + fun setOnPhotoEditorListener(onPhotoEditorListener: OnPhotoEditorListener) + + /** + * Check if any changes made need to save + * + * @return true if nothing is there to change + */ + val isCacheEmpty: Boolean + + /** + * Builder pattern to define [PhotoEditor] Instance + */ + class Builder(var context: Context, var photoEditorView: PhotoEditorView) { + + @JvmField + var imageView: ImageView = photoEditorView.source + + @JvmField + var deleteView: View? = null + + @JvmField + var drawingView: DrawingView = photoEditorView.drawingView + + @JvmField + var textTypeface: Typeface? = null + + @JvmField + var emojiTypeface: Typeface? = null + + // By default, pinch-to-scale is enabled for text + @JvmField + var isTextPinchScalable = true + + @JvmField + var clipSourceImage = false + fun setDeleteView(deleteView: View?): Builder { + this.deleteView = deleteView + return this + } + + /** + * set default text font to be added on image + * + * @param textTypeface typeface for custom font + * @return [Builder] instant to build [PhotoEditor] + */ + fun setDefaultTextTypeface(textTypeface: Typeface?): Builder { + this.textTypeface = textTypeface + return this + } + + /** + * set default font specific to add emojis + * + * @param emojiTypeface typeface for custom font + * @return [Builder] instant to build [PhotoEditor] + */ + fun setDefaultEmojiTypeface(emojiTypeface: Typeface?): Builder { + this.emojiTypeface = emojiTypeface + return this + } + + /** + * Set false to disable pinch-to-scale for text inserts. + * Set to "true" by default. + * + * @param isTextPinchScalable flag to make pinch to zoom for text inserts. + * @return [Builder] instant to build [PhotoEditor] + */ + fun setPinchTextScalable(isTextPinchScalable: Boolean): Builder { + this.isTextPinchScalable = isTextPinchScalable + return this + } + + /** + * @return build PhotoEditor instance + */ + fun build(): PhotoEditor { + return PhotoEditorImpl(this) + } + + /** + * Set true true to clip the drawing brush to the source image. + * + * @param clip a boolean to indicate if brush drawing is clipped or not. + */ + fun setClipSourceImage(clip: Boolean): Builder { + clipSourceImage = clip + return this + } + + } + + /** + * A callback to save the edited image asynchronously + */ + interface OnSaveListener { + /** + * Call when edited image is saved successfully on given path + * + * @param imagePath path on which image is saved + */ + fun onSuccess(imagePath: String) + + /** + * Call when failed to saved image on given path + * + * @param exception exception thrown while saving image + */ + fun onFailure(exception: Exception) + } + + // region Shape + /** + * Update the current shape to be drawn, + * through the use of a ShapeBuilder. + */ + fun setShape(shapeBuilder: ShapeBuilder) // endregion +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorImageViewListener.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorImageViewListener.kt new file mode 100644 index 0000000000..841a761ccd --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorImageViewListener.kt @@ -0,0 +1,44 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.MotionEvent + +// A listener for the image view that helps with the focus view logic. +// i.e when you press on an empty space without stickers, it will de-select the focused sticker. +internal class PhotoEditorImageViewListener( + private val viewState: PhotoEditorViewState, + private val onSingleTapUpCallback: OnSingleTapUpCallback +) : SimpleOnGestureListener() { + internal interface OnSingleTapUpCallback { + fun onSingleTapUp() + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + onSingleTapUpCallback.onSingleTapUp() + // Returning false when there is no in focus view will pass the + // touch event to the zoom layout logic. + return viewState.currentSelectedView != null + } + + override fun onDown(e: MotionEvent) = viewState.currentSelectedView != null + + override fun onFling( + e1: MotionEvent?, + event1: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean = viewState.currentSelectedView != null + + override fun onScroll( + e1: MotionEvent?, + event1: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean = viewState.currentSelectedView != null + + override fun onDoubleTap(event: MotionEvent) = viewState.currentSelectedView != null + + override fun onDoubleTapEvent(event: MotionEvent) = viewState.currentSelectedView != null + + override fun onSingleTapConfirmed(event: MotionEvent) = viewState.currentSelectedView != null +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorView.kt new file mode 100644 index 0000000000..0a10506f7f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorView.kt @@ -0,0 +1,171 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.util.AttributeSet +import android.util.Log +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.RelativeLayout +import com.dot.gallery.R +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView +import com.dot.gallery.feature_node.presentation.edit.components.views.FilterImageView +import com.dot.gallery.feature_node.presentation.edit.components.views.ImageFilterView + +/** + * + * + * This ViewGroup will have the [DrawingView] to draw paint on it with [ImageView] + * which our source image + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.1 + * @since 1/18/2018 + */ +class PhotoEditorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : RelativeLayout(context, attrs, defStyle) { + + private var mImgSource: FilterImageView = FilterImageView(context) + + internal var drawingView: DrawingView + private set + + private var mImageFilterView: ImageFilterView + private var clipSourceImage = false + + init { + //Setup image attributes + val sourceParam = setupImageSource(attrs) + //Setup GLSurface attributes + mImageFilterView = ImageFilterView(context) + val filterParam = setupFilterView() + + mImgSource.setOnImageChangedListener(object : FilterImageView.OnImageChangedListener { + override fun onBitmapLoaded(sourceBitmap: Bitmap?) { + mImageFilterView.setFilterEffect(PhotoFilter.NONE) + mImageFilterView.setSourceBitmap(sourceBitmap) + Log.d(TAG, "onBitmapLoaded() called with: sourceBitmap = [$sourceBitmap]") + } + }) + + + //Setup drawing view + drawingView = DrawingView(context) + val brushParam = setupDrawingView() + + //Add image source + addView(mImgSource, sourceParam) + + //Add Gl FilterView + addView(mImageFilterView, filterParam) + + //Add brush view + addView(drawingView, brushParam) + } + + @SuppressLint("Recycle") + private fun setupImageSource(attrs: AttributeSet?): LayoutParams { + mImgSource.id = imgSrcId + mImgSource.adjustViewBounds = true + mImgSource.scaleType = ImageView.ScaleType.CENTER_INSIDE + + attrs?.let { + val a = context.obtainStyledAttributes(it, R.styleable.PhotoEditorView) + val imgSrcDrawable = a.getDrawable(R.styleable.PhotoEditorView_photo_src) + if (imgSrcDrawable != null) { + mImgSource.setImageDrawable(imgSrcDrawable) + } + } + + var widthParam = ViewGroup.LayoutParams.MATCH_PARENT + if (clipSourceImage) { + widthParam = ViewGroup.LayoutParams.WRAP_CONTENT + } + val params = LayoutParams( + widthParam, ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.addRule(CENTER_IN_PARENT, TRUE) + return params + } + + private fun setupDrawingView(): LayoutParams { + drawingView.visibility = GONE + drawingView.id = shapeSrcId + + // Align drawing view to the size of image view + val params = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.addRule(CENTER_IN_PARENT, TRUE) + params.addRule(ALIGN_TOP, imgSrcId) + params.addRule(ALIGN_BOTTOM, imgSrcId) + params.addRule(ALIGN_LEFT, imgSrcId) + params.addRule(ALIGN_RIGHT, imgSrcId) + return params + } + + private fun setupFilterView(): LayoutParams { + mImageFilterView.visibility = GONE + mImageFilterView.id = glFilterId + + //Align brush to the size of image view + val params = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.addRule(CENTER_IN_PARENT, TRUE) + params.addRule(ALIGN_TOP, imgSrcId) + params.addRule(ALIGN_BOTTOM, imgSrcId) + return params + } + + /** + * Source image which you want to edit + * + * @return source ImageView + */ + val source: ImageView + get() = mImgSource + + internal suspend fun saveFilter(): Bitmap { + return if (mImageFilterView.visibility == VISIBLE) { + val saveBitmap = try { + mImageFilterView.saveBitmap() + } catch (t: Throwable) { + throw RuntimeException("Couldn't save bitmap with filter", t) + } + mImgSource.setImageBitmap(saveBitmap) + mImageFilterView.visibility = GONE + saveBitmap + } else { + mImgSource.bitmap!! + } + } + + internal fun setFilterEffect(filterType: PhotoFilter) { + mImageFilterView.visibility = VISIBLE + mImageFilterView.setFilterEffect(filterType) + } + + internal fun setFilterEffect(customEffect: CustomEffect?) { + mImageFilterView.visibility = VISIBLE + mImageFilterView.setFilterEffect(customEffect) + } + + internal fun setClipSourceImage(clip: Boolean) { + clipSourceImage = clip + val param = setupImageSource(null) + mImgSource.layoutParams = param + } // endregion + + companion object { + private const val TAG = "PhotoEditorView" + private const val imgSrcId = 1 + private const val shapeSrcId = 2 + private const val glFilterId = 3 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorViewState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorViewState.kt new file mode 100644 index 0000000000..d8f129d311 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoEditorViewState.kt @@ -0,0 +1,77 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.view.View +import java.util.Stack + +/** + * Tracked state of user-added views (stickers, emoji, text, etc) + */ +internal class PhotoEditorViewState { + var currentSelectedView: View? = null + private val addedViews: MutableList = ArrayList() + private val redoViews: Stack = Stack() + fun clearCurrentSelectedView() { + currentSelectedView = null + } + + fun getAddedView(index: Int): View { + return addedViews[index] + } + + val addedViewsCount: Int + get() = addedViews.size + + fun clearAddedViews() { + addedViews.clear() + } + + fun addAddedView(view: View) { + addedViews.add(view) + } + + fun removeAddedView(view: View) { + addedViews.remove(view) + } + + fun removeAddedView(index: Int): View { + return addedViews.removeAt(index) + } + + fun containsAddedView(view: View): Boolean { + return addedViews.contains(view) + } + + /** + * Replaces a view in the current "added views" list. + * + * @param view The view to replace + * @return true if the view was found and replaced, false if the view was not found + */ + fun replaceAddedView(view: View): Boolean { + val i = addedViews.indexOf(view) + if (i > -1) { + addedViews[i] = view + return true + } + return false + } + + fun clearRedoViews() { + redoViews.clear() + } + + fun pushRedoView(view: View) { + redoViews.push(view) + } + + fun popRedoView(): View { + return redoViews.pop() + } + + val redoViewsCount: Int + get() = redoViews.size + + fun getRedoView(index: Int): View { + return redoViews[index] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoFilter.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoFilter.kt new file mode 100644 index 0000000000..356d3d7d5e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoFilter.kt @@ -0,0 +1,42 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +/** + * + * + * Type of pre-defined filter effect for [ImageFilterView] + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.2 + * @see android.media.effect.EffectFactory + * + * @see android.media.effect.Effect + * + * @since 2/14/2018 + */ +enum class PhotoFilter { + NONE, + AUTO_FIX, + BLACK_WHITE, + BRIGHTNESS, + CONTRAST, + CROSS_PROCESS, + DOCUMENTARY, + DUE_TONE, + FILL_LIGHT, + FISH_EYE, + FLIP_VERTICAL, + FLIP_HORIZONTAL, + GRAIN, + GRAY_SCALE, + LOMISH, + NEGATIVE, + POSTERIZE, + ROTATE, + SATURATE, + SEPIA, + SHARPEN, + TEMPERATURE, + TINT, + VIGNETTE +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoSaverTask.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoSaverTask.kt new file mode 100644 index 0000000000..c90391f6a7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/PhotoSaverTask.kt @@ -0,0 +1,92 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import com.dot.gallery.feature_node.presentation.edit.components.utils.BitmapUtil.removeTransparency +import com.dot.gallery.feature_node.presentation.edit.components.views.DrawingView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Created by Burhanuddin Rashid on 18/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal class PhotoSaverTask( + private val photoEditorView: PhotoEditorView, + private val boxHelper: BoxHelper, + private var saveSettings: SaveSettings +) { + + private val drawingView: DrawingView = photoEditorView.drawingView + + private fun onBeforeSaveImage() { + boxHelper.clearHelperBox() + drawingView.destroyDrawingCache() + } + + fun saveImageAsBitmap(): Bitmap { + onBeforeSaveImage() + val bitmap = buildBitmap() + if (saveSettings.isClearViewsEnabled) { + boxHelper.clearAllViews(drawingView) + } + return bitmap + } + + suspend fun saveImageAsFile(imagePath: String): SaveFileResult { + onBeforeSaveImage() + val capturedBitmap = buildBitmap() + + val result = withContext(Dispatchers.IO) { + val file = File(imagePath) + try { + FileOutputStream(file, false).use { outputStream -> + capturedBitmap.compress( + saveSettings.compressFormat, + saveSettings.compressQuality, + outputStream + ) + outputStream.flush() + } + + SaveFileResult.Success + } catch (e: IOException) { + SaveFileResult.Failure(e) + } + } + + if (result is SaveFileResult.Success) { + // Clear all views if it's enabled in save settings + if (saveSettings.isClearViewsEnabled) { + boxHelper.clearAllViews(drawingView) + } + } + + return result + } + + private fun buildBitmap(): Bitmap { + return if (saveSettings.isTransparencyEnabled) { + removeTransparency(captureView(photoEditorView)) + } else { + captureView(photoEditorView) + } + } + + private fun captureView(view: View): Bitmap { + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + view.draw(canvas) + return bitmap + } + + companion object { + const val TAG = "PhotoSaverTask" + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveFileResult.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveFileResult.kt new file mode 100644 index 0000000000..b11d7a365c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveFileResult.kt @@ -0,0 +1,10 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import java.io.IOException + +sealed interface SaveFileResult { + + data object Success : SaveFileResult + class Failure(val exception: IOException) : SaveFileResult + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveSettings.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveSettings.kt new file mode 100644 index 0000000000..08b2ca1e61 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/SaveSettings.kt @@ -0,0 +1,79 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Bitmap.CompressFormat +import androidx.annotation.IntRange + +/** + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @since 8/8/2018 + * Builder Class to apply multiple save options + */ +class SaveSettings private constructor(builder: Builder) { + val isTransparencyEnabled: Boolean + val isClearViewsEnabled: Boolean + val compressFormat: CompressFormat + val compressQuality: Int + + class Builder { + @JvmField var isTransparencyEnabled = true + @JvmField var isClearViewsEnabled = true + @JvmField var compressFormat = CompressFormat.PNG + @JvmField var compressQuality = 100 + + /** + * Define a flag to enable transparency while saving image + * + * @param transparencyEnabled true if enabled + * @return Builder + * @see BitmapUtil.removeTransparency + */ + fun setTransparencyEnabled(transparencyEnabled: Boolean): Builder { + isTransparencyEnabled = transparencyEnabled + return this + } + + /** + * Define a flag to clear the view after saving the image + * + * @param clearViewsEnabled true if you want to clear all the views on [PhotoEditorView] + * @return Builder + */ + fun setClearViewsEnabled(clearViewsEnabled: Boolean): Builder { + isClearViewsEnabled = clearViewsEnabled + return this + } + + /** + * Set the compression format for the file to save: JPEG, PNG or WEBP + * @see{android.graphics.Bitmap.CompressFormat} + * @param compressFormat JPEG, PNG or WEBP + * @return Builder + */ + fun setCompressFormat(compressFormat: CompressFormat): Builder { + this.compressFormat = compressFormat + return this + } + + /** + * Set the expected compression quality for the output, a number between + * 0 and 100 + * @param compressQuality An integer from 0 to 100 + * @return Builder + */ + fun setCompressQuality(@IntRange(from = 0, to = 100) compressQuality: Int): Builder { + this.compressQuality = compressQuality + return this + } + + fun build(): SaveSettings { + return SaveSettings(this) + } + } + + init { + isClearViewsEnabled = builder.isClearViewsEnabled + isTransparencyEnabled = builder.isTransparencyEnabled + compressFormat = builder.compressFormat + compressQuality = builder.compressQuality + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ScaleGestureDetector.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ScaleGestureDetector.kt new file mode 100644 index 0000000000..56a6668a03 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ScaleGestureDetector.kt @@ -0,0 +1,497 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.dot.gallery.feature_node.presentation.edit.components.utils; + +import android.util.Log +import android.view.MotionEvent +import android.view.View +import com.dot.gallery.feature_node.presentation.edit.components.utils.ScaleGestureDetector.OnScaleGestureListener +import kotlin.math.sqrt + +/** + * Detects transformation gestures involving more than one pointer ("multitouch") + * using the supplied [MotionEvent]s. The [OnScaleGestureListener] + * callback will notify users when a particular gesture event has occurred. + * This class should only be used with [MotionEvent]s reported via touch. + * + * To use this class: + * + * * Create an instance of the `ScaleGestureDetector` for your + * [View] + * + */ +internal class ScaleGestureDetector(private val mListener: OnScaleGestureListener) { + /** + * The listener for receiving notifications when gestures occur. + * If you want to listen for all the different gestures then implement + * this interface. If you only want to listen for a subset it might + * be easier to extend [SimpleOnScaleGestureListener]. + * + * An application will receive events in the following order: + */ + internal interface OnScaleGestureListener { + /** + * Responds to scaling events for a gesture in progress. + * Reported by pointer motion. + * + * @param detector The detector reporting the event - use this to + * retrieve extended info about event state. + * @return Whether or not the detector should consider this event + * as handled. If an event was not handled, the detector + * will continue to accumulate movement until an event is + * handled. This can be useful if an application, for example, + * only wants to update scaling factors if the change is + * greater than 0.01. + */ + fun onScale(view: View, detector: ScaleGestureDetector): Boolean + + /** + * Responds to the beginning of a scaling gesture. Reported by + * new pointers going down. + * + * @param detector The detector reporting the event - use this to + * retrieve extended info about event state. + * @return Whether or not the detector should continue recognizing + * this gesture. For example, if a gesture is beginning + * with a focal point outside of a region where it makes + * sense, onScaleBegin() may return false to ignore the + * rest of the gesture. + */ + fun onScaleBegin(view: View, detector: ScaleGestureDetector): Boolean + + /** + * Responds to the end of a scale gesture. Reported by existing + * pointers going up. + * + * Once a scale has ended, [ScaleGestureDetector.getFocusX] + * and [ScaleGestureDetector.getFocusY] will return the location + * of the pointer remaining on the screen. + * + * @param detector The detector reporting the event - use this to + * retrieve extended info about event state. + */ + fun onScaleEnd(view: View, detector: ScaleGestureDetector) + } + + /** + * A convenience class to extend when you only want to listen for a subset + * of scaling-related events. This implements all methods in + */ + internal open class SimpleOnScaleGestureListener : OnScaleGestureListener { + override fun onScale(view: View, detector: ScaleGestureDetector): Boolean { + return false + } + + override fun onScaleBegin(view: View, detector: ScaleGestureDetector): Boolean { + return true + } + + override fun onScaleEnd(view: View, detector: ScaleGestureDetector) { + // Intentionally empty + } + } + + /** + * Returns `true` if a two-finger scale gesture is in progress. + * @return `true` if a scale gesture is in progress, `false` otherwise. + */ + var isInProgress = false + private set + private var mPrevEvent: MotionEvent? = null + private var mCurrEvent: MotionEvent? = null + private val mCurrSpanVector: Vector2D = Vector2D() + private var mFocusX = 0f + private var mFocusY = 0f + private var mPrevFingerDiffX = 0f + private var mPrevFingerDiffY = 0f + private var mCurrFingerDiffX = 0f + private var mCurrFingerDiffY = 0f + private var mCurrLen = 0f + private var mPrevLen = 0f + private var mScaleFactor = 0f + private var mCurrPressure = 0f + private var mPrevPressure = 0f + private var mTimeDelta: Long = 0 + private var mInvalidGesture = false + + // Pointer IDs currently responsible for the two fingers controlling the gesture + private var mActiveId0 = 0 + private var mActiveId1 = 0 + private var mActive0MostRecent = false + fun onTouchEvent(view: View, event: MotionEvent): Boolean { + val action = event.actionMasked + if (action == MotionEvent.ACTION_DOWN) { + reset() // Start fresh + } + var handled = true + if (mInvalidGesture) { + handled = false + } else if (!isInProgress) { + when (action) { + MotionEvent.ACTION_DOWN -> { + mActiveId0 = event.getPointerId(0) + mActive0MostRecent = true + } + MotionEvent.ACTION_UP -> reset() + MotionEvent.ACTION_POINTER_DOWN -> { + + // We have a new multi-finger gesture + mPrevEvent?.recycle() + mPrevEvent = MotionEvent.obtain(event) + mTimeDelta = 0 + val index1 = event.actionIndex + var index0 = event.findPointerIndex(mActiveId0) + mActiveId1 = event.getPointerId(index1) + if (index0 < 0 || index0 == index1) { + // Probably someone sending us a broken event stream. + index0 = findNewActiveIndex(event, mActiveId1, -1) + mActiveId0 = event.getPointerId(index0) + } + mActive0MostRecent = false + setContext(view, event) + isInProgress = mListener.onScaleBegin(view, this) + } + } + } else { + // Transform gesture in progress - attempt to handle it + when (action) { + MotionEvent.ACTION_POINTER_DOWN -> { + + // End the old gesture and begin a new one with the most recent two fingers. + mListener.onScaleEnd(view, this) + val oldActive0 = mActiveId0 + val oldActive1 = mActiveId1 + reset() + mPrevEvent = MotionEvent.obtain(event) + mActiveId0 = if (mActive0MostRecent) oldActive0 else oldActive1 + mActiveId1 = event.getPointerId(event.actionIndex) + mActive0MostRecent = false + var index0 = event.findPointerIndex(mActiveId0) + if (index0 < 0 || mActiveId0 == mActiveId1) { + // Probably someone sending us a broken event stream. + index0 = findNewActiveIndex(event, mActiveId1, -1) + mActiveId0 = event.getPointerId(index0) + } + setContext(view, event) + isInProgress = mListener.onScaleBegin(view, this) + } + MotionEvent.ACTION_POINTER_UP -> { + val pointerCount = event.pointerCount + val actionIndex = event.actionIndex + val actionId = event.getPointerId(actionIndex) + var gestureEnded = false + if (pointerCount > 2) { + if (actionId == mActiveId0) { + val newIndex = findNewActiveIndex(event, mActiveId1, actionIndex) + if (newIndex >= 0) { + mListener.onScaleEnd(view, this) + mActiveId0 = event.getPointerId(newIndex) + mActive0MostRecent = true + mPrevEvent = MotionEvent.obtain(event) + setContext(view, event) + isInProgress = mListener.onScaleBegin(view, this) + } else { + gestureEnded = true + } + } else if (actionId == mActiveId1) { + val newIndex = findNewActiveIndex(event, mActiveId0, actionIndex) + if (newIndex >= 0) { + mListener.onScaleEnd(view, this) + mActiveId1 = event.getPointerId(newIndex) + mActive0MostRecent = false + mPrevEvent = MotionEvent.obtain(event) + setContext(view, event) + isInProgress = mListener.onScaleBegin(view, this) + } else { + gestureEnded = true + } + } + mPrevEvent?.recycle() + mPrevEvent = MotionEvent.obtain(event) + setContext(view, event) + } else { + gestureEnded = true + } + if (gestureEnded) { + // Gesture ended + setContext(view, event) + + // Set focus point to the remaining finger + val activeId = if (actionId == mActiveId0) mActiveId1 else mActiveId0 + val index = event.findPointerIndex(activeId) + mFocusX = event.getX(index) + mFocusY = event.getY(index) + mListener.onScaleEnd(view, this) + reset() + mActiveId0 = activeId + mActive0MostRecent = true + } + } + MotionEvent.ACTION_CANCEL -> { + mListener.onScaleEnd(view, this) + reset() + } + MotionEvent.ACTION_UP -> reset() + MotionEvent.ACTION_MOVE -> { + setContext(view, event) + + // Only accept the event if our relative pressure is within + // a certain limit - this can help filter shaky data as a + // finger is lifted. + if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD) { + val updatePrevious = mListener.onScale(view, this) + if (updatePrevious) { + mPrevEvent?.recycle() + mPrevEvent = MotionEvent.obtain(event) + } + } + } + } + } + return handled + } + + private fun findNewActiveIndex( + ev: MotionEvent, + otherActiveId: Int, + removedPointerIndex: Int + ): Int { + val pointerCount = ev.pointerCount + + // It's ok if this isn't found and returns -1, it simply won't match. + val otherActiveIndex = ev.findPointerIndex(otherActiveId) + + // Pick a new id and update tracking state. + for (i in 0 until pointerCount) { + if (i != removedPointerIndex && i != otherActiveIndex) { + return i + } + } + return -1 + } + + private fun setContext(view: View, curr: MotionEvent) { + mCurrEvent?.recycle() + mCurrEvent = MotionEvent.obtain(curr) + mCurrLen = -1f + mPrevLen = -1f + mScaleFactor = -1f + mCurrSpanVector[0.0f] = 0.0f + + if (mPrevEvent == null) { + return + } + val prev = mPrevEvent!! + val prevIndex0 = prev.findPointerIndex(mActiveId0) + val prevIndex1 = prev.findPointerIndex(mActiveId1) + val currIndex0 = curr.findPointerIndex(mActiveId0) + val currIndex1 = curr.findPointerIndex(mActiveId1) + if (prevIndex0 < 0 || prevIndex1 < 0 || currIndex0 < 0 || currIndex1 < 0) { + mInvalidGesture = true + Log.e(TAG, "Invalid MotionEvent stream detected.", Throwable()) + if (isInProgress) { + mListener.onScaleEnd(view, this) + } + return + } + val px0 = prev.getX(prevIndex0) + val py0 = prev.getY(prevIndex0) + val px1 = prev.getX(prevIndex1) + val py1 = prev.getY(prevIndex1) + val cx0 = curr.getX(currIndex0) + val cy0 = curr.getY(currIndex0) + val cx1 = curr.getX(currIndex1) + val cy1 = curr.getY(currIndex1) + val pvx = px1 - px0 + val pvy = py1 - py0 + val cvx = cx1 - cx0 + val cvy = cy1 - cy0 + mCurrSpanVector[cvx] = cvy + mPrevFingerDiffX = pvx + mPrevFingerDiffY = pvy + mCurrFingerDiffX = cvx + mCurrFingerDiffY = cvy + mFocusX = cx0 + cvx * 0.5f + mFocusY = cy0 + cvy * 0.5f + mTimeDelta = curr.eventTime - prev.eventTime + mCurrPressure = curr.getPressure(currIndex0) + curr.getPressure(currIndex1) + mPrevPressure = prev.getPressure(prevIndex0) + prev.getPressure(prevIndex1) + } + + private fun reset() { + mPrevEvent?.recycle() + mPrevEvent = null + mCurrEvent?.recycle() + mCurrEvent = null + isInProgress = false + mActiveId0 = -1 + mActiveId1 = -1 + mInvalidGesture = false + } + + /** + * Get the X coordinate of the current gesture's focal point. + * If a gesture is in progress, the focal point is directly between + * the two pointers forming the gesture. + * If a gesture is ending, the focal point is the location of the + * remaining pointer on the screen. + * If [.isInProgress] would return false, the result of this + * function is undefined. + * + * @return X coordinate of the focal point in pixels. + */ + fun getFocusX(): Float { + return mFocusX + } + + /** + * Get the Y coordinate of the current gesture's focal point. + * If a gesture is in progress, the focal point is directly between + * the two pointers forming the gesture. + * If a gesture is ending, the focal point is the location of the + * remaining pointer on the screen. + * If [.isInProgress] would return false, the result of this + * function is undefined. + * + * @return Y coordinate of the focal point in pixels. + */ + fun getFocusY(): Float { + return mFocusY + } + + /** + * Return the current distance between the two pointers forming the + * gesture in progress. + * + * @return Distance between pointers in pixels. + */ + private fun getCurrentSpan(): Float { + if (mCurrLen == -1f) { + val cvx = mCurrFingerDiffX + val cvy = mCurrFingerDiffY + mCurrLen = Math.sqrt((cvx * cvx + cvy * cvy).toDouble()).toFloat() + } + return mCurrLen + } + + fun getCurrentSpanVector(): Vector2D { + return mCurrSpanVector + } + + /** + * Return the current x distance between the two pointers forming the + * gesture in progress. + * + * @return Distance between pointers in pixels. + */ + fun getCurrentSpanX(): Float { + return mCurrFingerDiffX + } + + /** + * Return the current y distance between the two pointers forming the + * gesture in progress. + * + * @return Distance between pointers in pixels. + */ + fun getCurrentSpanY(): Float { + return mCurrFingerDiffY + } + + /** + * Return the previous distance between the two pointers forming the + * gesture in progress. + * + * @return Previous distance between pointers in pixels. + */ + private fun getPreviousSpan(): Float { + if (mPrevLen == -1f) { + val pvx = mPrevFingerDiffX + val pvy = mPrevFingerDiffY + mPrevLen = sqrt((pvx * pvx + pvy * pvy).toDouble()).toFloat() + } + return mPrevLen + } + + /** + * Return the previous x distance between the two pointers forming the + * gesture in progress. + * + * @return Previous distance between pointers in pixels. + */ + fun getPreviousSpanX(): Float { + return mPrevFingerDiffX + } + + /** + * Return the previous y distance between the two pointers forming the + * gesture in progress. + * + * @return Previous distance between pointers in pixels. + */ + fun getPreviousSpanY(): Float { + return mPrevFingerDiffY + } + + /** + * Return the scaling factor from the previous scale event to the current + * event. This value is defined as + * ([.getCurrentSpan] / [.getPreviousSpan]). + * + * @return The current scaling factor. + */ + fun getScaleFactor(): Float { + if (mScaleFactor == -1f) { + mScaleFactor = getCurrentSpan() / getPreviousSpan() + } + return mScaleFactor + } + + /** + * Return the time difference in milliseconds between the previous + * accepted scaling event and the current scaling event. + * + * @return Time difference since the last scaling event in milliseconds. + */ + fun getTimeDelta(): Long { + return mTimeDelta + } + + /** + * Return the event time of the current event being processed. + * + * @return Current event time in milliseconds. + */ + fun getEventTime(): Long { + return mCurrEvent?.eventTime ?: 0L + } + + companion object { + private const val TAG = "ScaleGestureDetector" + + /** + * This value is the threshold ratio between our previous combined pressure + * and the current combined pressure. We will only fire an onScale event if + * the computed ratio between the current and previous event pressures is + * greater than this value. When pressure decreases rapidly between events + * the position values can often be imprecise, as it usually indicates + * that the user is in the process of lifting a pointer off of the device. + * Its value was tuned experimentally. + */ + private const val PRESSURE_THRESHOLD = 0.67f + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Sticker.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Sticker.kt new file mode 100644 index 0000000000..cd9ff06afd --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Sticker.kt @@ -0,0 +1,43 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Bitmap +import android.view.View +import android.widget.ImageView +import com.dot.gallery.R + +/** + * Created by Burhanuddin Rashid on 14/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal class Sticker( + private val mPhotoEditorView: PhotoEditorView, + private val mMultiTouchListener: MultiTouchListener, + private val mViewState: PhotoEditorViewState, + graphicManager: GraphicManager? +) : Graphic( + context = mPhotoEditorView.context, + graphicManager = graphicManager, + viewType = ViewType.IMAGE, + layoutId = R.layout.view_photo_editor_image +) { + private var imageView: ImageView? = null + fun buildView(desiredImage: Bitmap?) { + imageView?.setImageBitmap(desiredImage) + } + + private fun setupGesture() { + val onGestureControl = buildGestureController(mPhotoEditorView, mViewState) + mMultiTouchListener.setOnGestureControl(onGestureControl) + val rootView = rootView + rootView.setOnTouchListener(mMultiTouchListener) + } + + override fun setupView(rootView: View) { + imageView = rootView.findViewById(R.id.imgPhotoEditorImage) + } + + init { + setupGesture() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Text.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Text.kt new file mode 100644 index 0000000000..6810ee850d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Text.kt @@ -0,0 +1,61 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Typeface +import android.view.Gravity +import android.view.View +import android.widget.TextView +import com.dot.gallery.R + +/** + * Created by Burhanuddin Rashid on 14/05/21. + * + * @author //github.com/burhanrashid52> + */ +internal class Text( + private val mPhotoEditorView: PhotoEditorView, + private val mMultiTouchListener: MultiTouchListener, + private val mViewState: PhotoEditorViewState, + private val mDefaultTextTypeface: Typeface?, + private val mGraphicManager: GraphicManager +) : Graphic( + context = mPhotoEditorView.context, + graphicManager = mGraphicManager, + viewType = ViewType.TEXT, + layoutId = R.layout.view_photo_editor_text +) { + + private var mTextView: TextView? = null + + fun buildView(text: String?, styleBuilder: TextStyleBuilder?) { + mTextView?.apply { + this.text = text + styleBuilder?.applyStyle(this) + } + } + + private fun setupGesture() { + val onGestureControl = buildGestureController(mPhotoEditorView, mViewState) + mMultiTouchListener.setOnGestureControl(onGestureControl) + val rootView = rootView + rootView.setOnTouchListener(mMultiTouchListener) + } + + override fun setupView(rootView: View) { + mTextView = rootView.findViewById(R.id.tvPhotoEditorText) + mTextView?.run { + gravity = Gravity.CENTER + typeface = mDefaultTextTypeface + } + } + + override fun updateView(view: View) { + val textInput = mTextView?.text.toString() + val currentTextColor = mTextView?.currentTextColor ?: 0 + val photoEditorListener = mGraphicManager.onPhotoEditorListener + photoEditorListener?.onEditTextChangeListener(view, textInput, currentTextColor) + } + + init { + setupGesture() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextStyleBuilder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextStyleBuilder.kt new file mode 100644 index 0000000000..31b3a8b24b --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextStyleBuilder.kt @@ -0,0 +1,259 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.widget.TextView +import com.dot.gallery.feature_node.presentation.edit.components.utils.text.TextBorder +import com.dot.gallery.feature_node.presentation.edit.components.utils.text.TextShadow + +/** + * + * + * This class is used to wrap the styles to apply on the TextView on [PhotoEditor.addText] and [PhotoEditor.editText] + * + * + * @author [Christian Caballero](https://github.com/Sulfkain) + * @since 14/05/2019 + */ +open class TextStyleBuilder { + val values = mutableMapOf() + + /** + * Set this textSize style + * + * @param size Size to apply on text + */ + fun withTextSize(size: Float) { + values[TextStyle.SIZE] = size + } + + /** + * Set this textShadow style + * + * @param radius Radius of the shadow to apply on text + * @param dx Horizontal distance of the shadow + * @param dy Vertical distance of the shadow + * @param color Color of the shadow + */ + fun withTextShadow(radius: Float, dx: Float, dy: Float, color: Int) { + val shadow = TextShadow(radius, dx, dy, color) + withTextShadow(shadow) + } + + /** + * Set this color style + * + * @param color Color to apply on text + */ + fun withTextColor(color: Int) { + values[TextStyle.COLOR] = color + } + + /** + * Set this [Typeface] style + * + * @param textTypeface TypeFace to apply on text + */ + fun withTextFont(textTypeface: Typeface) { + values[TextStyle.FONT_FAMILY] = textTypeface + } + + /** + * Set this gravity style + * + * @param gravity Gravity style to apply on text + */ + fun withGravity(gravity: Int) { + values[TextStyle.GRAVITY] = gravity + } + + /** + * Set this background color + * + * @param background Background color to apply on text, this method overrides the preview set on [TextStyleBuilder.withBackgroundDrawable] + */ + fun withBackgroundColor(background: Int) { + values[TextStyle.BACKGROUND] = background + } + + /** + * Set this background [Drawable], this method overrides the preview set on [TextStyleBuilder.withBackgroundColor] + * + * @param bgDrawable Background drawable to apply on text + */ + fun withBackgroundDrawable(bgDrawable: Drawable) { + values[TextStyle.BACKGROUND] = bgDrawable + } + + /** + * Set this textAppearance style + * + * @param textAppearance Text style to apply on text + */ + fun withTextAppearance(textAppearance: Int) { + values[TextStyle.TEXT_APPEARANCE] = + textAppearance + } + + fun withTextStyle(typeface: Int) { + values[TextStyle.TEXT_STYLE] = typeface + } + + fun withTextFlag(paintFlag: Int) { + values[TextStyle.TEXT_FLAG] = paintFlag + } + + fun withTextShadow(textShadow: TextShadow) { + values[TextStyle.SHADOW] = textShadow + } + + fun withTextBorder(textBorder: TextBorder) { + values[TextStyle.BORDER] = textBorder + } + + /** + * Method to apply all the style setup on this Builder} + * + * @param textView TextView to apply the style + */ + fun applyStyle(textView: TextView) { + for ((key, value) in values) { + when (key) { + TextStyle.SIZE -> { + val size = value as Float + applyTextSize(textView, size) + } + TextStyle.COLOR -> { + val color = value as Int + applyTextColor(textView, color) + } + TextStyle.FONT_FAMILY -> { + val typeface = value as Typeface + applyFontFamily(textView, typeface) + } + TextStyle.GRAVITY -> { + val gravity = value as Int + applyGravity(textView, gravity) + } + TextStyle.BACKGROUND -> { + if (value is Drawable) { + applyBackgroundDrawable(textView, value) + } else if (value is Int) { + applyBackgroundColor(textView, value) + } + } + TextStyle.TEXT_APPEARANCE -> { + if (value is Int) { + applyTextAppearance(textView, value) + } + } + TextStyle.TEXT_STYLE -> { + val typeface = value as Int + applyTextStyle(textView, typeface) + } + TextStyle.TEXT_FLAG -> { + val flag = value as Int + applyTextFlag(textView, flag) + } + TextStyle.SHADOW -> { + run { + if (value is TextShadow) { + applyTextShadow(textView, value) + } + } + run { + if (value is TextBorder) { + applyTextBorder(textView, value) + } + } + } + TextStyle.BORDER -> { + if (value is TextBorder) { + applyTextBorder(textView, value) + } + } + } + } + } + + protected open fun applyTextSize(textView: TextView, size: Float) { + textView.textSize = size + } + + protected fun applyTextShadow( + textView: TextView, + radius: Float, + dx: Float, + dy: Float, + color: Int + ) { + textView.setShadowLayer(radius, dx, dy, color) + } + + protected open fun applyTextColor(textView: TextView, color: Int) { + textView.setTextColor(color) + } + + protected open fun applyFontFamily(textView: TextView, typeface: Typeface?) { + textView.typeface = typeface + } + + protected open fun applyGravity(textView: TextView, gravity: Int) { + textView.gravity = gravity + } + + protected open fun applyBackgroundColor(textView: TextView, color: Int) { + textView.setBackgroundColor(color) + } + + protected open fun applyBackgroundDrawable(textView: TextView, bg: Drawable?) { + textView.background = bg + } + + // border + protected open fun applyTextBorder(textView: TextView, textBorder: TextBorder) { + val gd = GradientDrawable() + gd.cornerRadius = textBorder.corner + gd.setStroke(textBorder.strokeWidth, textBorder.strokeColor) + gd.setColor(textBorder.backGroundColor) + textView.background = gd + } + + // shadow + protected open fun applyTextShadow(textView: TextView, textShadow: TextShadow) { + textView.setShadowLayer(textShadow.radius, textShadow.dx, textShadow.dy, textShadow.color) + } + + // bold or italic + protected open fun applyTextStyle(textView: TextView, typeface: Int) { + textView.setTypeface(textView.typeface, typeface) + } + + // underline or strike + protected open fun applyTextFlag(textView: TextView, flag: Int) { +// textView.setPaintFlags(textView.getPaintFlags()|flag); + textView.paint.flags = flag + } + + protected open fun applyTextAppearance(textView: TextView, styleAppearance: Int) { + textView.setTextAppearance(styleAppearance) + } + + /** + * Enum to maintain current supported style properties used on on [PhotoEditor.addText] and [PhotoEditor.editText] + */ + enum class TextStyle(val property: String) { + SIZE("TextSize"), + COLOR("TextColor"), + GRAVITY("Gravity"), + FONT_FAMILY("FontFamily"), + BACKGROUND("Background"), + TEXT_APPEARANCE("TextAppearance"), + TEXT_STYLE("TextStyle"), + TEXT_FLAG("TextFlag"), + SHADOW("Shadow"), + BORDER("Border"); + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextureRenderer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextureRenderer.kt new file mode 100644 index 0000000000..566258c1bb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/TextureRenderer.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.dot.gallery.feature_node.presentation.edit.components.utils + +import android.opengl.GLES20 +import com.dot.gallery.feature_node.presentation.edit.components.utils.GLToolbox.checkGlError +import com.dot.gallery.feature_node.presentation.edit.components.utils.GLToolbox.createProgram +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer + +internal class TextureRenderer { + private var mProgram = 0 + private var mTexSamplerHandle = 0 + private var mTexCoordHandle = 0 + private var mPosCoordHandle = 0 + private var mTexVertices: FloatBuffer? = null + private var mPosVertices: FloatBuffer? = null + private var mViewWidth = 0 + private var mViewHeight = 0 + private var mTexWidth = 0 + private var mTexHeight = 0 + fun init() { + // Create program + mProgram = createProgram(VERTEX_SHADER, FRAGMENT_SHADER) + + // Bind attributes and uniforms + mTexSamplerHandle = GLES20.glGetUniformLocation( + mProgram, + "tex_sampler" + ) + mTexCoordHandle = GLES20.glGetAttribLocation(mProgram, "a_texcoord") + mPosCoordHandle = GLES20.glGetAttribLocation(mProgram, "a_position") + + // Setup coordinate buffers + mTexVertices = ByteBuffer.allocateDirect( + TEX_VERTICES.size * FLOAT_SIZE_BYTES + ) + .order(ByteOrder.nativeOrder()).asFloatBuffer() + mTexVertices?.put(TEX_VERTICES)?.position(0) + mPosVertices = ByteBuffer.allocateDirect( + POS_VERTICES.size * FLOAT_SIZE_BYTES + ) + .order(ByteOrder.nativeOrder()).asFloatBuffer() + mPosVertices?.put(POS_VERTICES)?.position(0) + } + + fun tearDown() { + GLES20.glDeleteProgram(mProgram) + } + + fun updateTextureSize(texWidth: Int, texHeight: Int) { + mTexWidth = texWidth + mTexHeight = texHeight + computeOutputVertices() + } + + fun updateViewSize(viewWidth: Int, viewHeight: Int) { + mViewWidth = viewWidth + mViewHeight = viewHeight + computeOutputVertices() + } + + fun renderTexture(texId: Int) { + // Bind default FBO + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0) + + // Use our shader program + GLES20.glUseProgram(mProgram) + checkGlError("glUseProgram") + + // Set viewport + GLES20.glViewport(0, 0, mViewWidth, mViewHeight) + checkGlError("glViewport") + + // Disable blending + GLES20.glDisable(GLES20.GL_BLEND) + + // Set the vertex attributes + GLES20.glVertexAttribPointer( + mTexCoordHandle, 2, GLES20.GL_FLOAT, false, + 0, mTexVertices + ) + GLES20.glEnableVertexAttribArray(mTexCoordHandle) + GLES20.glVertexAttribPointer( + mPosCoordHandle, 2, GLES20.GL_FLOAT, false, + 0, mPosVertices + ) + GLES20.glEnableVertexAttribArray(mPosCoordHandle) + checkGlError("vertex attribute setup") + + // Set the input texture + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + checkGlError("glActiveTexture") + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId) + checkGlError("glBindTexture") + GLES20.glUniform1i(mTexSamplerHandle, 0) + + // Draw + GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f) + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + } + + private fun computeOutputVertices() { + val imgAspectRatio = mTexWidth / mTexHeight.toFloat() + val viewAspectRatio = mViewWidth / mViewHeight.toFloat() + val relativeAspectRatio = viewAspectRatio / imgAspectRatio + val x0: Float + val y0: Float + val x1: Float + val y1: Float + if (relativeAspectRatio > 1.0f) { + x0 = -1.0f / relativeAspectRatio + y0 = -1.0f + x1 = 1.0f / relativeAspectRatio + y1 = 1.0f + } else { + x0 = -1.0f + y0 = -relativeAspectRatio + x1 = 1.0f + y1 = relativeAspectRatio + } + val coords = floatArrayOf(x0, y0, x1, y0, x0, y1, x1, y1) + mPosVertices?.put(coords)?.position(0) + } + + companion object { + private const val VERTEX_SHADER = "attribute vec4 a_position;\n" + + "attribute vec2 a_texcoord;\n" + + "varying vec2 v_texcoord;\n" + + "void main() {\n" + + " gl_Position = a_position;\n" + + " v_texcoord = a_texcoord;\n" + + "}\n" + private const val FRAGMENT_SHADER = "precision mediump float;\n" + + "uniform sampler2D tex_sampler;\n" + + "varying vec2 v_texcoord;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(tex_sampler, v_texcoord);\n" + + "}\n" + private val TEX_VERTICES = floatArrayOf( + 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f + ) + private val POS_VERTICES = floatArrayOf( + -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f + ) + private const val FLOAT_SIZE_BYTES = 4 + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Vector2D.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Vector2D.kt new file mode 100644 index 0000000000..096a1cc193 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/Vector2D.kt @@ -0,0 +1,28 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +import android.graphics.PointF +import kotlin.math.atan2 +import kotlin.math.sqrt + +internal class Vector2D : PointF { + constructor() : super() {} + constructor(x: Float, y: Float) : super(x, y) {} + + private fun normalize() { + val length = sqrt((x * x + y * y).toDouble()).toFloat() + x /= length + y /= length + } + + companion object { + fun getAngle(vector1: Vector2D, vector2: Vector2D): Float { + vector1.normalize() + vector2.normalize() + val degrees = 180.0 / Math.PI * (atan2( + vector2.y.toDouble(), + vector2.x.toDouble() + ) - atan2(vector1.y.toDouble(), vector1.x.toDouble())) + return degrees.toFloat() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ViewType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ViewType.kt new file mode 100644 index 0000000000..16d13a7e02 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/ViewType.kt @@ -0,0 +1,18 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils + +/** + * + * + * Enum define for various operation happening on the [PhotoEditorView] while editing + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.1 + * @since 18/01/2017. + */ +enum class ViewType { + BRUSH_DRAWING, + TEXT, + IMAGE, + EMOJI +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/AbstractShape.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/AbstractShape.kt new file mode 100644 index 0000000000..2c3311e653 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/AbstractShape.kt @@ -0,0 +1,42 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF + +abstract class AbstractShape(protected val tag: String) : Shape { + protected var path = Path() + protected var left = 0f + protected var top = 0f + protected var right = 0f + protected var bottom = 0f + + override fun draw(canvas: Canvas, paint: Paint) { + canvas.drawPath(path, paint) + } + + private val bounds: RectF + get() { + val bounds = RectF() + path.computeBounds(bounds, true) + return bounds + } + + fun hasBeenTapped(): Boolean { + val bounds = bounds + return bounds.top < TOUCH_TOLERANCE && bounds.bottom < TOUCH_TOLERANCE && bounds.left < TOUCH_TOLERANCE && bounds.right < TOUCH_TOLERANCE + } + + override fun toString(): String { + return tag + + ": left: " + left + + " - top: " + top + + " - right: " + right + + " - bottom: " + bottom + } + + companion object { + const val TOUCH_TOLERANCE = 4f + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ArrowPointerLocation.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ArrowPointerLocation.kt new file mode 100644 index 0000000000..b0a37a7a8f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ArrowPointerLocation.kt @@ -0,0 +1,3 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +enum class ArrowPointerLocation { START, END, BOTH } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/BrushShape.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/BrushShape.kt new file mode 100644 index 0000000000..803a982499 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/BrushShape.kt @@ -0,0 +1,34 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Canvas +import android.graphics.Paint +import android.util.Log +import kotlin.math.abs + +class BrushShape : AbstractShape("BrushShape") { + + override fun draw(canvas: Canvas, paint: Paint) { + canvas.drawPath(path, paint) + } + + override fun startShape(x: Float, y: Float) { + Log.d(tag, "startShape@ $x,$y") + path.moveTo(x, y) + left = x + top = y + } + + override fun moveShape(x: Float, y: Float) { + val dx = abs(x - left) + val dy = abs(y - top) + if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) { + path.quadTo(left, top, (x + left) / 2, (y + top) / 2) + left = x + top = y + } + } + + override fun stopShape() { + Log.d(tag, "stopShape") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/LineShape.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/LineShape.kt new file mode 100644 index 0000000000..44ca0aadbe --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/LineShape.kt @@ -0,0 +1,103 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.content.Context +import android.graphics.Path +import android.util.Log +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin + +class LineShape( + context: Context, + private val pointerLocation: ArrowPointerLocation? = null +) : AbstractShape("LineShape") { + + private val maxArrowRadius = convertDpsToPixels(context, MAX_ARROW_RADIUS_DP).toFloat() + + private var lastX = 0f + private var lastY = 0f + + override fun startShape(x: Float, y: Float) { + Log.d(tag, "startShape@ $x,$y") + left = x + top = y + } + + override fun moveShape(x: Float, y: Float) { + right = x + bottom = y + val dx = abs(x - lastX) + val dy = abs(y - lastY) + if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) { + path = createLinePath() + lastX = x + lastY = y + } + } + + private fun createLinePath(): Path { + val path = Path() + + if (pointerLocation == ArrowPointerLocation.BOTH || pointerLocation == ArrowPointerLocation.START) { + drawArrow(path, right, bottom, left, top) + } + + if (pointerLocation == ArrowPointerLocation.BOTH || pointerLocation == ArrowPointerLocation.END) { + drawArrow(path, left, top, right, bottom) + } + + path.moveTo(left, top) + path.lineTo(right, bottom) + path.close() + + return path + } + + private fun drawArrow(path: Path, fromX: Float, fromY: Float, toX: Float, toY: Float) { + // Based on: https://stackoverflow.com/a/41734848/1219654 + + val xDistance = toX - fromX + val yDistance = toY - fromY + + val lineAngle = atan2(yDistance, xDistance) + val arrowRadius = (hypot(xDistance, yDistance) / 2.5f).coerceAtMost(maxArrowRadius) + + val anglePointerA = lineAngle - ANGLE_RAD + val anglePointerB = lineAngle + ANGLE_RAD + + path.moveTo(toX, toY) + path.lineTo( + (toX - arrowRadius * cos(anglePointerA)), + (toY - arrowRadius * sin(anglePointerA)) + ) + + path.moveTo(toX, toY) + path.lineTo( + (toX - arrowRadius * cos(anglePointerB)), + (toY - arrowRadius * sin(anglePointerB)) + ) + } + + override fun stopShape() { + Log.d(tag, "stopShape") + } + + private companion object { + + const val ARROW_ANGLE = 30.0 + const val ANGLE_RAD = (PI * ARROW_ANGLE / 180.0).toFloat() + const val MAX_ARROW_RADIUS_DP = 32.0f + + fun convertDpsToPixels(context: Context, sizeDp: Float): Int { + // Convert the dps to pixels + val scale = context.resources.displayMetrics.density + // Use sizePx as a size in pixels + return (sizeDp * scale + 0.5f).toInt() + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/OvalShape.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/OvalShape.kt new file mode 100644 index 0000000000..2118322849 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/OvalShape.kt @@ -0,0 +1,42 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Path +import android.graphics.RectF +import android.util.Log +import kotlin.math.abs + +class OvalShape : AbstractShape("OvalShape") { + private var lastX = 0f + private var lastY = 0f + + override fun startShape(x: Float, y: Float) { + Log.d(tag, "startShape@ $x,$y") + left = x + top = y + } + + override fun moveShape(x: Float, y: Float) { + right = x + bottom = y + val dx = abs(x - lastX) + val dy = abs(y - lastY) + if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) { + path = createOvalPath() + lastX = x + lastY = y + } + } + + private fun createOvalPath(): Path { + val rect = RectF(left, top, right, bottom) + val path = Path() + path.moveTo(left, top) + path.addOval(rect, Path.Direction.CW) + path.close() + return path + } + + override fun stopShape() { + Log.d(tag, "stopShape") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/RectangleShape.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/RectangleShape.kt new file mode 100644 index 0000000000..46b3af7b58 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/RectangleShape.kt @@ -0,0 +1,42 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Path +import android.util.Log +import kotlin.math.abs + +class RectangleShape : AbstractShape("RectangleShape") { + private var lastX = 0f + private var lastY = 0f + + override fun startShape(x: Float, y: Float) { + Log.d(tag, "startShape@ $x,$y") + left = x + top = y + } + + override fun moveShape(x: Float, y: Float) { + right = x + bottom = y + val dx = abs(x - lastX) + val dy = abs(y - lastY) + if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) { + path = createRectanglePath() + lastX = x + lastY = y + } + } + + private fun createRectanglePath(): Path { + val path = Path() + path.moveTo(left, top) + path.lineTo(left, bottom) + path.lineTo(right, bottom) + path.lineTo(right, top) + path.close() + return path + } + + override fun stopShape() { + Log.d(tag, "stopShape") + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/Shape.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/Shape.kt new file mode 100644 index 0000000000..93261aec13 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/Shape.kt @@ -0,0 +1,11 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Canvas +import android.graphics.Paint + +interface Shape { + fun draw(canvas: Canvas, paint: Paint) + fun startShape(x: Float, y: Float) + fun moveShape(x: Float, y: Float) + fun stopShape() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeAndPaint.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeAndPaint.kt new file mode 100644 index 0000000000..c49670980d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeAndPaint.kt @@ -0,0 +1,11 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Paint + +/** + * Simple data class to be put in an ordered Stack + */ +open class ShapeAndPaint( + val shape: AbstractShape, + val paint: Paint +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeBuilder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeBuilder.kt new file mode 100644 index 0000000000..2dd8186b81 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeBuilder.kt @@ -0,0 +1,60 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +import android.graphics.Color +import androidx.annotation.ColorInt + +/** + * + * + * Used to hold a Shape parameters: type, size, opacity and color. + * + */ +class ShapeBuilder { + + var shapeType: ShapeType = ShapeType.Brush + private set + + var shapeSize: Float = DEFAULT_SHAPE_SIZE + private set + + @androidx.annotation.IntRange(from = 0, to = 255) + var shapeOpacity: Int? = DEFAULT_SHAPE_OPACITY + private set + + @get:ColorInt + @ColorInt + var shapeColor: Int = DEFAULT_SHAPE_COLOR + private set + + fun withShapeType(shapeType: ShapeType): ShapeBuilder { + this.shapeType = shapeType + return this + } + + fun withShapeSize(size: Float): ShapeBuilder { + shapeSize = size + return this + } + + fun withShapeOpacity( + @androidx.annotation.IntRange( + from = 0, + to = 255 + ) opacity: Int? + ): ShapeBuilder { + shapeOpacity = opacity + return this + } + + fun withShapeColor(@ColorInt color: Int): ShapeBuilder { + shapeColor = color + return this + } + + companion object { + const val DEFAULT_SHAPE_SIZE = 25.0f + val DEFAULT_SHAPE_OPACITY = null + const val DEFAULT_SHAPE_COLOR = Color.BLACK + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeType.kt new file mode 100644 index 0000000000..d0f9c80f29 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/shapes/ShapeType.kt @@ -0,0 +1,14 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.shapes + +/** + * The different kind of known Shapes. + */ +sealed interface ShapeType { + + data object Brush : ShapeType + data object Oval : ShapeType + data object Rectangle : ShapeType + data object Line : ShapeType + class Arrow(val pointerLocation: ArrowPointerLocation = ArrowPointerLocation.START) : ShapeType + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextBorder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextBorder.kt new file mode 100644 index 0000000000..36acdbea81 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextBorder.kt @@ -0,0 +1,8 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.text; + +class TextBorder( + var corner: Float, + var backGroundColor: Int, + var strokeWidth: Int, + var strokeColor: Int +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextShadow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextShadow.kt new file mode 100644 index 0000000000..833b215abb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/utils/text/TextShadow.kt @@ -0,0 +1,8 @@ +package com.dot.gallery.feature_node.presentation.edit.components.utils.text; + +data class TextShadow( + var radius: Float, + var dx: Float, + var dy: Float, + var color: Int +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/DrawingView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/DrawingView.kt new file mode 100644 index 0000000000..59d2daab7f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/DrawingView.kt @@ -0,0 +1,231 @@ +package com.dot.gallery.feature_node.presentation.edit.components.views + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.util.AttributeSet +import android.util.Pair +import android.view.MotionEvent +import android.view.View +import com.dot.gallery.feature_node.presentation.edit.components.utils.BrushViewChangeListener +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.AbstractShape +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.BrushShape +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.LineShape +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.OvalShape +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.RectangleShape +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.ShapeAndPaint +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.ShapeBuilder +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.ShapeType +import java.util.Stack + +/** + * + * + * This is custom drawing view used to do painting on user touch events it it will paint on canvas + * as per attributes provided to the paint + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.1 + * @since 12/1/18 + */ +class DrawingView @JvmOverloads constructor( + context: Context?, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : View(context, attrs, defStyle) { + private val drawShapes = Stack() + private val redoShapes = Stack() + internal var currentShape: ShapeAndPaint? = null + var isDrawingEnabled = false + private set + private var viewChangeListener: BrushViewChangeListener? = null + var currentShapeBuilder: ShapeBuilder + + // eraser parameters + private var isErasing = false + var eraserSize = DEFAULT_ERASER_SIZE + + // endregion + private fun createPaint(): Paint { + val paint = Paint() + paint.isAntiAlias = true + paint.isDither = true + paint.style = Paint.Style.STROKE + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER) + + // apply shape builder parameters + currentShapeBuilder.apply { + paint.strokeWidth = this.shapeSize + // 'paint.color' must be called before 'paint.alpha', + // otherwise 'paint.alpha' value will be overwritten. + paint.color = this.shapeColor + shapeOpacity?.also { paint.alpha = it } + } + + return paint + } + + private fun createEraserPaint(): Paint { + val paint = createPaint() + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + return paint + } + + fun clearAll() { + drawShapes.clear() + redoShapes.clear() + invalidate() + } + + fun setBrushViewChangeListener(brushViewChangeListener: BrushViewChangeListener?) { + viewChangeListener = brushViewChangeListener + } + + public override fun onDraw(canvas: Canvas) { + for (shape in drawShapes) { + shape?.shape?.draw(canvas, shape.paint) + } + } + + /** + * Handle touch event to draw paint on canvas i.e brush drawing + * + * @param event points having touch info + * @return true if handling touch events + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + return if (isDrawingEnabled) { + val touchX = event.x + val touchY = event.y + when (event.action) { + MotionEvent.ACTION_DOWN -> onTouchEventDown(touchX, touchY) + MotionEvent.ACTION_MOVE -> onTouchEventMove(touchX, touchY) + MotionEvent.ACTION_UP -> onTouchEventUp() + } + invalidate() + true + } else { + false + } + } + + private fun onTouchEventDown(touchX: Float, touchY: Float) { + createShape() + currentShape?.shape?.startShape(touchX, touchY) + } + + private fun onTouchEventMove(touchX: Float, touchY: Float) { + currentShape?.shape?.moveShape(touchX, touchY) + } + + private fun onTouchEventUp() { + currentShape?.apply { + shape.stopShape() + endShape() + } + } + + private fun createShape() { + var paint = createPaint() + var shape: AbstractShape = BrushShape() + + if (isErasing) { + paint = createEraserPaint() + } else { + when (val shapeType = currentShapeBuilder.shapeType) { + ShapeType.Oval -> { + shape = OvalShape() + } + ShapeType.Brush -> { + shape = BrushShape() + } + ShapeType.Rectangle -> { + shape = RectangleShape() + } + ShapeType.Line -> { + shape = LineShape(context) + } + is ShapeType.Arrow -> { + shape = LineShape(context, shapeType.pointerLocation) + } + } + } + + currentShape = ShapeAndPaint(shape, paint) + drawShapes.push(currentShape) + viewChangeListener?.onStartDrawing() + } + + private fun endShape() { + if (currentShape?.shape?.hasBeenTapped() == true) { + // just a tap, this is not a shape, so remove it + drawShapes.remove(currentShape) + //handleTap(touchX, touchY); + } + viewChangeListener?.apply { + onStopDrawing() + if(redoShapes.isNotEmpty()) { + redoShapes.clear() + } + onViewAdd(this@DrawingView) + } + } + + fun undo(): Boolean { + if (!drawShapes.empty()) { + redoShapes.push(drawShapes.pop()) + invalidate() + } + viewChangeListener?.onViewRemoved(this) + return !drawShapes.empty() + } + + fun redo(): Boolean { + if (!redoShapes.empty()) { + drawShapes.push(redoShapes.pop()) + invalidate() + } + viewChangeListener?.onViewAdd(this) + return !redoShapes.empty() + } + + // region eraser + fun brushEraser() { + isDrawingEnabled = true + isErasing = true + } + + // endregion + // region Setters/Getters + + fun enableDrawing(brushDrawMode: Boolean) { + isDrawingEnabled = brushDrawMode + isErasing = !brushDrawMode + if (brushDrawMode) { + visibility = VISIBLE + } + } + + // endregion + val drawingPath: Pair, Stack> + get() = Pair(drawShapes, redoShapes) + + companion object { + const val DEFAULT_ERASER_SIZE = 50.0f + } + + // region constructors + init { + //Caution: This line is to disable hardware acceleration to make eraser feature work properly + setLayerType(LAYER_TYPE_HARDWARE, null) + visibility = GONE + currentShapeBuilder = ShapeBuilder() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/FilterImageView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/FilterImageView.kt new file mode 100644 index 0000000000..b397205bfb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/FilterImageView.kt @@ -0,0 +1,94 @@ +package com.dot.gallery.feature_node.presentation.edit.components.views + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.Matrix +import android.graphics.PorterDuff +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.net.Uri +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView + +/** + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.2 + * @since 5/21/2018 + */ +internal class FilterImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : AppCompatImageView(context, attrs, defStyle) { + + private var mOnImageChangedListener: OnImageChangedListener? = null + + fun setOnImageChangedListener(onImageChangedListener: OnImageChangedListener?) { + mOnImageChangedListener = onImageChangedListener + } + + internal interface OnImageChangedListener { + fun onBitmapLoaded(sourceBitmap: Bitmap?) + } + + override fun setImageBitmap(bm: Bitmap) { + super.setImageBitmap(bm) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + } + + override fun setImageIcon(icon: Icon?) { + super.setImageIcon(icon) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + } + + override fun setImageMatrix(matrix: Matrix) { + super.setImageMatrix(matrix) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + } + + override fun setImageState(state: IntArray, merge: Boolean) { + super.setImageState(state, merge) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + } + + override fun setImageTintList(tint: ColorStateList?) { + super.setImageTintList(tint) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + + } + + override fun setImageTintMode(tintMode: PorterDuff.Mode?) { + super.setImageTintMode(tintMode) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + } + + override fun setImageDrawable(drawable: Drawable?) { + super.setImageDrawable(drawable) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + + } + + override fun setImageResource(resId: Int) { + super.setImageResource(resId) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + + } + + override fun setImageURI(uri: Uri?) { + super.setImageURI(uri) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + + } + + override fun setImageLevel(level: Int) { + super.setImageLevel(level) + mOnImageChangedListener?.onBitmapLoaded(bitmap) + } + + val bitmap: Bitmap? + get() = if (drawable is BitmapDrawable) { + (drawable as BitmapDrawable).bitmap + } else null +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/ImageFilterView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/ImageFilterView.kt new file mode 100644 index 0000000000..99db91d989 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/ImageFilterView.kt @@ -0,0 +1,269 @@ +package com.dot.gallery.feature_node.presentation.edit.components.views + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.media.effect.Effect +import android.media.effect.EffectContext +import android.media.effect.EffectFactory +import android.opengl.GLES20 +import android.opengl.GLSurfaceView +import android.opengl.GLUtils +import android.util.AttributeSet +import com.dot.gallery.feature_node.presentation.edit.components.utils.BitmapUtil.createBitmapFromGLSurface +import com.dot.gallery.feature_node.presentation.edit.components.utils.CustomEffect +import com.dot.gallery.feature_node.presentation.edit.components.utils.GLToolbox.initTexParams +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoFilter +import com.dot.gallery.feature_node.presentation.edit.components.utils.TextureRenderer +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.opengles.GL10 +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * + * + * Filter Images using ImageFilterView + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.2 + * @since 2/14/2018 + */ +internal class ImageFilterView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : GLSurfaceView(context, attrs), GLSurfaceView.Renderer { + + private val mTextures = IntArray(2) + private var mEffectContext: EffectContext? = null + private var mEffect: Effect? = null + private val mTexRenderer: TextureRenderer = TextureRenderer() + private var mImageWidth = 0 + private var mImageHeight = 0 + private var mInitialized = false + private var mCurrentEffect: PhotoFilter = PhotoFilter.NONE + private var mSourceBitmap: Bitmap? = null + private var mCustomEffect: CustomEffect? = null + private var bitmapReadyContinuation: Continuation? = null + private val mutex = Mutex() + val hasFilterApplied get() = mCurrentEffect != PhotoFilter.NONE || mCustomEffect != null + val currentFilter get() = mCurrentEffect + val currentCustomFilter get() = mCustomEffect + + init { + setEGLContextClientVersion(2) + setRenderer(this) + renderMode = RENDERMODE_WHEN_DIRTY + setFilterEffect(PhotoFilter.NONE) + } + + internal fun setSourceBitmap(sourceBitmap: Bitmap?) { + /* if (mSourceBitmap != null && mSourceBitmap.sameAs(sourceBitmap)) { + //mCurrentEffect = NONE; + }*/ + mSourceBitmap = sourceBitmap + mInitialized = false + } + + override fun onSurfaceCreated(gl: GL10, config: EGLConfig) {} + override fun onSurfaceChanged(gl: GL10, width: Int, height: Int) { + mTexRenderer.updateViewSize(width, height) + } + + override fun onDrawFrame(gl: GL10) { + try { + if (!mInitialized) { + //Only need to do this once + mEffectContext = EffectContext.createWithCurrentGlContext() + mTexRenderer.init() + loadTextures() + mInitialized = true + } + if (mCurrentEffect != PhotoFilter.NONE || mCustomEffect != null) { + //if an effect is chosen initialize it and apply it to the texture + initEffect() + applyEffect() + } + renderResult() + } catch (t: Throwable) { + val continuation = bitmapReadyContinuation + if (continuation != null) { + bitmapReadyContinuation = null + continuation.resumeWithException(t) + } else { + throw t + } + } + + val continuation = bitmapReadyContinuation + if (continuation != null) { + bitmapReadyContinuation = null + + val filterBitmap = try { + createBitmapFromGLSurface(this, gl) + } catch (t: Throwable) { + continuation.resumeWithException(t) + null + } + + if (filterBitmap != null) continuation.resume(filterBitmap) + } + } + + internal fun setFilterEffect(effect: PhotoFilter) { + mCurrentEffect = effect + mCustomEffect = null + requestRender() + } + + internal fun setFilterEffect(customEffect: CustomEffect?) { + mCustomEffect = customEffect + requestRender() + } + + internal suspend fun saveBitmap(): Bitmap = mutex.withLock { + suspendCoroutine { continuation -> + bitmapReadyContinuation = continuation + requestRender() + } + } + + private fun loadTextures() { + // Generate textures + GLES20.glGenTextures(2, mTextures, 0) + + // Load input bitmap + mSourceBitmap?.let { + mImageWidth = it.width + mImageHeight = it.height + mTexRenderer.updateTextureSize(mImageWidth, mImageHeight) + + // Upload to texture + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextures[0]) + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, it, 0) + + // Set texture parameters + initTexParams() + } + } + + private fun initEffect() { + mEffectContext?.factory?.apply { + mEffect?.release() + + if (mCustomEffect != null) { + mEffect = createEffect(mCustomEffect!!.effectName) + val parameters = mCustomEffect!!.parameters + for ((key, value) in parameters) { + mEffect?.setParameter(key, value) + } + } else { + // Initialize the correct effect based on the selected menu/action item + when (mCurrentEffect) { + PhotoFilter.AUTO_FIX -> { + mEffect = createEffect(EffectFactory.EFFECT_AUTOFIX) + mEffect?.setParameter("scale", 0.5f) + } + PhotoFilter.BLACK_WHITE -> { + mEffect = createEffect(EffectFactory.EFFECT_BLACKWHITE) + mEffect?.setParameter("black", .1f) + mEffect?.setParameter("white", .7f) + } + PhotoFilter.BRIGHTNESS -> { + mEffect = createEffect(EffectFactory.EFFECT_BRIGHTNESS) + mEffect?.setParameter("brightness", 2.0f) + } + PhotoFilter.CONTRAST -> { + mEffect = createEffect(EffectFactory.EFFECT_CONTRAST) + mEffect?.setParameter("contrast", 1.4f) + } + PhotoFilter.CROSS_PROCESS -> mEffect = + createEffect(EffectFactory.EFFECT_CROSSPROCESS) + PhotoFilter.DOCUMENTARY -> mEffect = + createEffect(EffectFactory.EFFECT_DOCUMENTARY) + PhotoFilter.DUE_TONE -> { + mEffect = createEffect(EffectFactory.EFFECT_DUOTONE) + mEffect?.setParameter("first_color", Color.YELLOW) + mEffect?.setParameter("second_color", Color.DKGRAY) + } + PhotoFilter.FILL_LIGHT -> { + mEffect = createEffect(EffectFactory.EFFECT_FILLLIGHT) + mEffect?.setParameter("strength", .8f) + } + PhotoFilter.FISH_EYE -> { + mEffect = createEffect(EffectFactory.EFFECT_FISHEYE) + mEffect?.setParameter("scale", .5f) + } + PhotoFilter.FLIP_HORIZONTAL -> { + mEffect = createEffect(EffectFactory.EFFECT_FLIP) + mEffect?.setParameter("horizontal", true) + } + PhotoFilter.FLIP_VERTICAL -> { + mEffect = createEffect(EffectFactory.EFFECT_FLIP) + mEffect?.setParameter("vertical", true) + } + PhotoFilter.GRAIN -> { + mEffect = createEffect(EffectFactory.EFFECT_GRAIN) + mEffect?.setParameter("strength", 1.0f) + } + PhotoFilter.GRAY_SCALE -> mEffect = + createEffect(EffectFactory.EFFECT_GRAYSCALE) + PhotoFilter.LOMISH -> mEffect = + createEffect(EffectFactory.EFFECT_LOMOISH) + PhotoFilter.NEGATIVE -> mEffect = + createEffect(EffectFactory.EFFECT_NEGATIVE) + PhotoFilter.NONE -> {} + PhotoFilter.POSTERIZE -> mEffect = + createEffect(EffectFactory.EFFECT_POSTERIZE) + PhotoFilter.ROTATE -> { + mEffect = createEffect(EffectFactory.EFFECT_ROTATE) + mEffect?.setParameter("angle", 180) + } + PhotoFilter.SATURATE -> { + mEffect = createEffect(EffectFactory.EFFECT_SATURATE) + mEffect?.setParameter("scale", .5f) + } + PhotoFilter.SEPIA -> mEffect = + createEffect(EffectFactory.EFFECT_SEPIA) + PhotoFilter.SHARPEN -> mEffect = + createEffect(EffectFactory.EFFECT_SHARPEN) + PhotoFilter.TEMPERATURE -> { + mEffect = createEffect(EffectFactory.EFFECT_TEMPERATURE) + mEffect?.setParameter("scale", .9f) + } + PhotoFilter.TINT -> { + mEffect = createEffect(EffectFactory.EFFECT_TINT) + mEffect?.setParameter("tint", Color.MAGENTA) + } + PhotoFilter.VIGNETTE -> { + mEffect = createEffect(EffectFactory.EFFECT_VIGNETTE) + mEffect?.setParameter("scale", .5f) + } + } + } + } + } + + private fun applyEffect() { + mEffect?.apply(mTextures[0], mImageWidth, mImageHeight, mTextures[1]) + } + + private fun renderResult() { + if (mCurrentEffect != PhotoFilter.NONE || mCustomEffect != null) { + // if no effect is chosen, just render the original bitmap + mTexRenderer.renderTexture(mTextures[1]) + } else { + // render the result of applyEffect() + mTexRenderer.renderTexture(mTextures[0]) + } + } + + companion object { + private const val TAG = "ImageFilterView" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/PhotoEditorImpl.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/PhotoEditorImpl.kt new file mode 100644 index 0000000000..d445b94929 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/edit/components/views/PhotoEditorImpl.kt @@ -0,0 +1,319 @@ +package com.dot.gallery.feature_node.presentation.edit.components.views + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Typeface +import android.text.TextUtils +import android.view.GestureDetector +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.IntRange +import androidx.annotation.RequiresPermission +import com.dot.gallery.R +import com.dot.gallery.feature_node.presentation.edit.components.utils.BoxHelper +import com.dot.gallery.feature_node.presentation.edit.components.utils.BrushDrawingStateListener +import com.dot.gallery.feature_node.presentation.edit.components.utils.CustomEffect +import com.dot.gallery.feature_node.presentation.edit.components.utils.Emoji +import com.dot.gallery.feature_node.presentation.edit.components.utils.Graphic +import com.dot.gallery.feature_node.presentation.edit.components.utils.GraphicManager +import com.dot.gallery.feature_node.presentation.edit.components.utils.MultiTouchListener +import com.dot.gallery.feature_node.presentation.edit.components.utils.OnPhotoEditorListener +import com.dot.gallery.feature_node.presentation.edit.components.utils.OnSaveBitmap +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoEditor +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoEditorImageViewListener +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoEditorView +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoEditorViewState +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoFilter +import com.dot.gallery.feature_node.presentation.edit.components.utils.PhotoSaverTask +import com.dot.gallery.feature_node.presentation.edit.components.utils.SaveFileResult +import com.dot.gallery.feature_node.presentation.edit.components.utils.SaveSettings +import com.dot.gallery.feature_node.presentation.edit.components.utils.Sticker +import com.dot.gallery.feature_node.presentation.edit.components.utils.Text +import com.dot.gallery.feature_node.presentation.edit.components.utils.TextStyleBuilder +import com.dot.gallery.feature_node.presentation.edit.components.utils.shapes.ShapeBuilder +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * + * + * This class in initialize by [PhotoEditor.Builder] using a builder pattern with multiple + * editing attributes + * + * + * @author [Burhanuddin Rashid](https://github.com/burhanrashid52) + * @version 0.1.1 + * @since 18/01/2017 + */ +@OptIn(DelicateCoroutinesApi::class) +internal class PhotoEditorImpl @SuppressLint("ClickableViewAccessibility") constructor( + builder: PhotoEditor.Builder +) : PhotoEditor { + private val photoEditorView: PhotoEditorView = builder.photoEditorView + private val viewState: PhotoEditorViewState = PhotoEditorViewState() + private val imageView: ImageView = builder.imageView + private val deleteView: View? = builder.deleteView + private val drawingView: DrawingView = builder.drawingView + private val mBrushDrawingStateListener: BrushDrawingStateListener = + BrushDrawingStateListener(builder.photoEditorView, viewState) + private val mBoxHelper: BoxHelper = BoxHelper(builder.photoEditorView, viewState) + private var mOnPhotoEditorListener: OnPhotoEditorListener? = null + private val isTextPinchScalable: Boolean = builder.isTextPinchScalable + private val mDefaultTextTypeface: Typeface? = builder.textTypeface + private val mDefaultEmojiTypeface: Typeface? = builder.emojiTypeface + private val mGraphicManager: GraphicManager = GraphicManager(builder.photoEditorView, viewState) + private val context: Context = builder.context + + override fun addImage(desiredImage: Bitmap) { + val multiTouchListener = getMultiTouchListener(true) + val sticker = Sticker(photoEditorView, multiTouchListener, viewState, mGraphicManager) + sticker.buildView(desiredImage) + addToEditor(sticker) + } + + override fun addText(text: String, colorCodeTextView: Int) { + addText(null, text, colorCodeTextView) + } + + override fun addText(textTypeface: Typeface?, text: String, colorCodeTextView: Int) { + val styleBuilder = TextStyleBuilder() + styleBuilder.withTextColor(colorCodeTextView) + if (textTypeface != null) { + styleBuilder.withTextFont(textTypeface) + } + addText(text, styleBuilder) + } + + override fun addText(text: String, styleBuilder: TextStyleBuilder?) { + drawingView.enableDrawing(false) + val multiTouchListener = getMultiTouchListener(isTextPinchScalable) + val textGraphic = Text( + photoEditorView, + multiTouchListener, + viewState, + mDefaultTextTypeface, + mGraphicManager + ) + textGraphic.buildView(text, styleBuilder) + addToEditor(textGraphic) + } + + override fun editText(view: View, inputText: String, colorCode: Int) { + editText(view, null, inputText, colorCode) + } + + override fun editText(view: View, textTypeface: Typeface?, inputText: String, colorCode: Int) { + val styleBuilder = TextStyleBuilder() + styleBuilder.withTextColor(colorCode) + if (textTypeface != null) { + styleBuilder.withTextFont(textTypeface) + } + editText(view, inputText, styleBuilder) + } + + override fun editText(view: View, inputText: String, styleBuilder: TextStyleBuilder?) { + val inputTextView = view.findViewById(R.id.tvPhotoEditorText) + if (inputTextView != null && viewState.containsAddedView(view) && !TextUtils.isEmpty( + inputText + ) + ) { + inputTextView.text = inputText + styleBuilder?.applyStyle(inputTextView) + mGraphicManager.updateView(view) + } + } + + override fun addEmoji(emojiName: String) { + addEmoji(null, emojiName) + } + + override fun addEmoji(emojiTypeface: Typeface?, emojiName: String) { + drawingView.enableDrawing(false) + val multiTouchListener = getMultiTouchListener(true) + val emoji = Emoji( + photoEditorView, + multiTouchListener, + viewState, + mGraphicManager, + mDefaultEmojiTypeface + ) + emoji.buildView(emojiTypeface, emojiName) + addToEditor(emoji) + } + + private fun addToEditor(graphic: Graphic) { + clearHelperBox() + mGraphicManager.addView(graphic) + // Change the in-focus view + viewState.currentSelectedView = graphic.rootView + } + + /** + * Create a new instance and scalable touchview + * + * @param isPinchScalable true if make pinch-scalable, false otherwise. + * @return scalable multitouch listener + */ + private fun getMultiTouchListener(isPinchScalable: Boolean): MultiTouchListener { + return MultiTouchListener( + deleteView, + photoEditorView, + imageView, + isPinchScalable, + mOnPhotoEditorListener, + viewState + ) + } + + override fun setBrushDrawingMode(brushDrawingMode: Boolean) { + drawingView.enableDrawing(brushDrawingMode) + } + + override val brushDrawableMode: Boolean + get() = drawingView.isDrawingEnabled + + @Deprecated("use {@code setShape} of a ShapeBuilder\n \n ") + override fun setOpacity(@IntRange(from = 0, to = 100) opacity: Int) { + var opacityValue = opacity + opacityValue = (opacityValue / 100.0 * 255.0).toInt() + drawingView.currentShapeBuilder.withShapeOpacity(opacityValue) + } + + override var brushSize: Float + get() = drawingView.currentShapeBuilder.shapeSize + set(size) { + drawingView.currentShapeBuilder.withShapeSize(size) + } + override var brushColor: Int + get() = drawingView.currentShapeBuilder.shapeColor + set(color) { + drawingView.currentShapeBuilder.withShapeColor(color) + } + + override fun setBrushEraserSize(brushEraserSize: Float) { + drawingView.eraserSize = brushEraserSize + } + + override val eraserSize: Float + get() = drawingView.eraserSize + + override fun brushEraser() { + drawingView.brushEraser() + } + + override fun undo(): Boolean { + return mGraphicManager.undoView() + } + + override fun redo(): Boolean { + return mGraphicManager.redoView() + } + + override fun clearAllViews() { + mBoxHelper.clearAllViews(drawingView) + } + + override fun clearHelperBox() { + mBoxHelper.clearHelperBox() + } + + override fun setFilterEffect(customEffect: CustomEffect?) { + photoEditorView.setFilterEffect(customEffect) + } + + override fun setFilterEffect(filterType: PhotoFilter) { + photoEditorView.setFilterEffect(filterType) + } + + @RequiresPermission(allOf = [Manifest.permission.WRITE_EXTERNAL_STORAGE]) + override suspend fun saveAsFile( + imagePath: String, + saveSettings: SaveSettings + ): SaveFileResult = withContext(Dispatchers.Main) { + photoEditorView.saveFilter() + val photoSaverTask = PhotoSaverTask(photoEditorView, mBoxHelper, saveSettings) + return@withContext photoSaverTask.saveImageAsFile(imagePath) + } + + override suspend fun saveAsBitmap( + saveSettings: SaveSettings + ): Bitmap = withContext(Dispatchers.Main) { + photoEditorView.saveFilter() + val photoSaverTask = PhotoSaverTask(photoEditorView, mBoxHelper, saveSettings) + return@withContext photoSaverTask.saveImageAsBitmap() + } + + @RequiresPermission(allOf = [Manifest.permission.WRITE_EXTERNAL_STORAGE]) + override fun saveAsFile( + imagePath: String, + saveSettings: SaveSettings, + onSaveListener: PhotoEditor.OnSaveListener + ) { + GlobalScope.launch(Dispatchers.Main) { + when (val result = saveAsFile(imagePath, saveSettings)) { + is SaveFileResult.Success -> onSaveListener.onSuccess(imagePath) + is SaveFileResult.Failure -> onSaveListener.onFailure(result.exception) + } + } + } + + @RequiresPermission(allOf = [Manifest.permission.WRITE_EXTERNAL_STORAGE]) + override fun saveAsFile(imagePath: String, onSaveListener: PhotoEditor.OnSaveListener) { + saveAsFile(imagePath, SaveSettings.Builder().build(), onSaveListener) + } + + override fun saveAsBitmap(saveSettings: SaveSettings, onSaveBitmap: OnSaveBitmap) { + GlobalScope.launch(Dispatchers.Main) { + val bitmap = saveAsBitmap(saveSettings) + onSaveBitmap.onBitmapReady(bitmap) + } + } + + override fun saveAsBitmap(onSaveBitmap: OnSaveBitmap) { + saveAsBitmap(SaveSettings.Builder().build(), onSaveBitmap) + } + + override fun setOnPhotoEditorListener(onPhotoEditorListener: OnPhotoEditorListener) { + mOnPhotoEditorListener = onPhotoEditorListener + mGraphicManager.onPhotoEditorListener = mOnPhotoEditorListener + mBrushDrawingStateListener.setOnPhotoEditorListener(mOnPhotoEditorListener) + } + + override val isCacheEmpty: Boolean + get() = viewState.addedViewsCount == 0 && viewState.redoViewsCount == 0 + + // region Shape + override fun setShape(shapeBuilder: ShapeBuilder) { + drawingView.currentShapeBuilder = shapeBuilder + } // endregion + + companion object { + private const val TAG = "PhotoEditor" + } + + init { + drawingView.setBrushViewChangeListener(mBrushDrawingStateListener) + val mDetector = GestureDetector( + context, + PhotoEditorImageViewListener( + viewState, + object : PhotoEditorImageViewListener.OnSingleTapUpCallback { + override fun onSingleTapUp() { + clearHelperBox() + } + } + ) + ) + imageView.setOnTouchListener { _, event -> + mOnPhotoEditorListener?.onTouchSourceImage(event) + mDetector.onTouchEvent(event) + } + photoEditorView.setClipSourceImage(builder.clipSourceImage) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt index d93f86ef2a..be807dd613 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/AddAlbumSheet.kt @@ -58,13 +58,7 @@ fun AddAlbumSheet( onCancel() } }, - dragHandle = { DragHandle() }, - windowInsets = WindowInsets( - 0, - WindowInsets.statusBars.getTop(LocalDensity.current), - 0, - 0 - ) + dragHandle = { DragHandle() } ) { BackHandler { scope.launch { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt index b2b0f75641..41b6efb417 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/CopyMediaSheet.kt @@ -106,7 +106,6 @@ fun CopyMediaSheet( val shouldDismiss = progress == 0f ModalBottomSheetProperties( securePolicy = SecureFlagPolicy.Inherit, - isFocusable = shouldDismiss, shouldDismissOnBackPress = shouldDismiss ) } @@ -123,7 +122,7 @@ fun CopyMediaSheet( }, properties = prop, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { Column( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt index df099a8dc6..02157d00df 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MetadataEditSheet.kt @@ -109,7 +109,7 @@ fun MetadataEditSheet( } }, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt index 783cf42407..4026debdae 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/exif/MoveMediaSheet.kt @@ -112,13 +112,7 @@ fun MoveMediaSheet( sheetState.hide() } }, - dragHandle = { DragHandle() }, - windowInsets = WindowInsets( - 0, - WindowInsets.statusBars.getTop(LocalDensity.current), - 0, - 0 - ) + dragHandle = { DragHandle() } ) { Column( modifier = Modifier diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt index 113345cf6b..2428e14d16 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredScreen.kt @@ -1,13 +1,21 @@ package com.dot.gallery.feature_node.presentation.ignored +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.PhotoAlbum import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -25,11 +33,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel @@ -38,15 +48,16 @@ import com.dot.gallery.R import com.dot.gallery.core.AlbumState import com.dot.gallery.core.Position import com.dot.gallery.core.SettingsEntity -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.settings.components.SettingsItem -import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import kotlinx.coroutines.launch +import com.dot.gallery.ui.core.icons.RegularExpression +import com.dot.gallery.ui.core.Icons as GalleryIcons @OptIn(ExperimentalMaterial3Api::class) @Composable fun IgnoredScreen( navigateUp: () -> Unit, + startSetup: () -> Unit, albumsState: AlbumState, ) { val vm = hiltViewModel() @@ -54,11 +65,10 @@ fun IgnoredScreen( val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val state by vm.blacklistState.collectAsStateWithLifecycle(IgnoredState()) - val selectAlbumState = rememberAppBottomSheetState() - val scope = rememberCoroutineScope() var toBeRemoved by remember(state) { - mutableStateOf(null) + mutableStateOf(null) } + val context = LocalContext.current Scaffold( topBar = { LargeTopAppBar( @@ -87,11 +97,28 @@ fun IgnoredScreen( LazyColumn( modifier = Modifier.fillMaxSize() ) { - - if (state.albums.isNotEmpty()) { + item { + Text( + modifier = Modifier + .padding(16.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp) + ) + .padding(16.dp), + text = stringResource(R.string.ignored_albums_text), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + if (state.albums.isEmpty()) { + item { + NoIgnoredAlbums() + } + } else { item { SettingsItem( - item = SettingsEntity.Header(stringResource(R.string.ignored_albums)) + item = SettingsEntity.Header(stringResource(R.string.created)) ) } } @@ -106,9 +133,27 @@ fun IgnoredScreen( } else if (index == state.albums.size - 1) Position.Bottom else Position.Middle } + val wildcardSummary: String = remember(blacklistedAlbum, albumsState) { + if (blacklistedAlbum.wildcard != null) { + context.getString( + R.string.wildcard_summary_first, + blacklistedAlbum.wildcard, + blacklistedAlbum.matchedAlbums.joinToString() + ) + } else context.getString( + R.string.matched_albums, + blacklistedAlbum.matchedAlbums.joinToString() + ) + } SettingsItem( item = SettingsEntity.Preference( + icon = remember(blacklistedAlbum) { + if (blacklistedAlbum.wildcard != null) { + GalleryIcons.RegularExpression + } else Icons.Outlined.PhotoAlbum + }, title = blacklistedAlbum.label, + summary = wildcardSummary, screenPosition = position, onClick = { toBeRemoved = blacklistedAlbum @@ -120,9 +165,7 @@ fun IgnoredScreen( FloatingActionButton( onClick = { - scope.launch { - selectAlbumState.show() - } + startSetup() }, modifier = Modifier .padding(32.dp) @@ -176,15 +219,44 @@ fun IgnoredScreen( ) ) } +} - SelectAlbumSheet( - sheetState = selectAlbumState, - blacklistedAlbums = state.albums, - albumState = albumsState, - onSelect = { - vm.addToBlacklist(BlacklistedAlbum(it.id, it.label)) +@Composable +fun NoIgnoredAlbums(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + ) { + val alphas = floatArrayOf(0.6f, 0.4f, 0.2f) + Column( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + alphas.forEach { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = it), + shape = RoundedCornerShape(2.dp) + ) + .clip(RoundedCornerShape(2.dp)) + ) + } } - ) + Text( + text = stringResource(R.string.no_ignored_albums), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt index 2a53ea458e..2c140e33b0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredState.kt @@ -2,12 +2,11 @@ package com.dot.gallery.feature_node.presentation.ignored import android.os.Parcelable import androidx.compose.runtime.Immutable -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import kotlinx.parcelize.Parcelize - @Immutable @Parcelize data class IgnoredState( - val albums: List = emptyList() + val albums: List = emptyList() ): Parcelable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt index 3adf5fe70e..d0d2b21001 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/IgnoredViewModel.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.domain.use_case.MediaUseCases import com.dot.gallery.feature_node.presentation.util.RepeatOnResume import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,22 +34,16 @@ class IgnoredViewModel @Inject constructor( } } - fun addToBlacklist(blacklistedAlbum: BlacklistedAlbum) { + fun removeFromBlacklist(ignoredAlbum: IgnoredAlbum) { viewModelScope.launch { - mediaUseCases.blacklistUseCase.addToBlacklist(blacklistedAlbum) - } - } - - fun removeFromBlacklist(blacklistedAlbum: BlacklistedAlbum) { - viewModelScope.launch { - mediaUseCases.blacklistUseCase.removeFromBlacklist(blacklistedAlbum) + mediaUseCases.blacklistUseCase.removeFromBlacklist(ignoredAlbum) } } private fun getIgnoredAlbums() { viewModelScope.launch { mediaUseCases.blacklistUseCase.blacklistedAlbums.collectLatest { - _ignoredState.tryEmit(IgnoredState(it)) + _ignoredState.emit(IgnoredState(it)) } } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetup.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetup.kt new file mode 100644 index 0000000000..20290a46dc --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetup.kt @@ -0,0 +1,161 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.dot.gallery.core.AlbumState +import com.dot.gallery.core.Constants.Animation.navigateInAnimation +import com.dot.gallery.core.Constants.Animation.navigateUpAnimation +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.domain.model.matchesAlbum +import com.dot.gallery.feature_node.presentation.ignored.setup.screens.SetupConfirmationScreen +import com.dot.gallery.feature_node.presentation.ignored.setup.screens.SetupLabelScreen +import com.dot.gallery.feature_node.presentation.ignored.setup.screens.SetupLocationScreen +import com.dot.gallery.feature_node.presentation.ignored.setup.screens.SetupTypeRegexScreen +import com.dot.gallery.feature_node.presentation.ignored.setup.screens.SetupTypeSelectionScreen +import java.util.UUID + +@Composable +fun IgnoredSetup( + onCancel: () -> Unit, + albumState: AlbumState, +) { + val vm = hiltViewModel().apply { + attachToLifecycle() + } + val navController = rememberNavController() + + val uiState by vm.uiState.collectAsStateWithLifecycle() + + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = IgnoredSetupDestination.Label(), + enterTransition = { navigateInAnimation }, + exitTransition = { navigateUpAnimation }, + popEnterTransition = { navigateInAnimation }, + popExitTransition = { navigateUpAnimation } + ) { + composable( + route = IgnoredSetupDestination.Label() + ) { + SetupLabelScreen( + onGoBack = { + vm.reset() + onCancel() + }, + onNext = { + if (vm.isLabelError) return@SetupLabelScreen + navController.navigate(IgnoredSetupDestination.Location()) + }, + isError = vm.isLabelError, + initialLabel = uiState.label, + onLabelChanged = { newLabel -> + vm.setLabel(newLabel) + } + ) + } + composable( + route = IgnoredSetupDestination.Location() + ) { + SetupLocationScreen( + onGoBack = navController::navigateUp, + onNext = { + navController.navigate(IgnoredSetupDestination.Type()) + }, + initialLocation = uiState.location, + initialType = uiState.type, + isError = false, + onLocationChanged = { newLocation -> + vm.setLocation(newLocation) + }, + onTypeChanged = { newType -> + vm.setType(newType) + } + ) + } + composable( + route = IgnoredSetupDestination.Type() + ) { + val ignoredAlbums by vm.blacklistState.collectAsStateWithLifecycle() + when (uiState.type) { + is IgnoredType.SELECTION -> { + SetupTypeSelectionScreen( + onGoBack = navController::navigateUp, + onNext = { + navController.navigate(IgnoredSetupDestination.MatchedAlbums()) + }, + initialAlbum = (uiState.type as IgnoredType.SELECTION).selectedAlbum, + onAlbumChanged = { + vm.setType(IgnoredType.SELECTION(it)) + }, + ignoredAlbums = ignoredAlbums.albums, + albumsState = albumState, + ) + } + + is IgnoredType.REGEX -> { + SetupTypeRegexScreen( + onGoBack = navController::navigateUp, + onNext = { + navController.navigate(IgnoredSetupDestination.MatchedAlbums()) + }, + initialRegex = (uiState.type as IgnoredType.REGEX).regex, + ignoredAlbums = ignoredAlbums.albums, + onRegexChanged = { + vm.setType(IgnoredType.REGEX(it)) + } + ) + } + } + } + composable( + route = IgnoredSetupDestination.MatchedAlbums() + ) { + LaunchedEffect(uiState) { + if (uiState.type is IgnoredType.REGEX) { + if ((uiState.type as IgnoredType.REGEX).regex.isEmpty()) return@LaunchedEffect + try { + val regex = (uiState.type as IgnoredType.REGEX).regex.toRegex() + val matchedAlbums = albumState.albums.filter(regex::matchesAlbum) + vm.setMatchedAlbums(matchedAlbums) + } catch (e: Exception) { + e.printStackTrace() + vm.setMatchedAlbums(emptyList()) + } + } else if (uiState.type is IgnoredType.SELECTION) { + val selectedAlbum = (uiState.type as IgnoredType.SELECTION).selectedAlbum!! + vm.setMatchedAlbums(listOf(selectedAlbum)) + } + } + + SetupConfirmationScreen( + onGoBack = navController::navigateUp, + onNext = { + val ignored = IgnoredAlbum( + id = if (uiState.type is IgnoredType.SELECTION) (uiState.type as IgnoredType.SELECTION).selectedAlbum!!.id else UUID.randomUUID().mostSignificantBits, + label = uiState.label, + location = uiState.location, + wildcard = if (uiState.type is IgnoredType.REGEX) (uiState.type as IgnoredType.REGEX).regex else null, + matchedAlbums = uiState.matchedAlbums.map { it.label }.ifEmpty { listOf((uiState.type as IgnoredType.SELECTION).selectedAlbum!!.label) } + ) + vm.addToIgnored(ignored) + onCancel() + }, + matchedAlbums = uiState.matchedAlbums, + location = uiState.location, + type = uiState.type + ) + } + } +} + + + diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupDestination.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupDestination.kt new file mode 100644 index 0000000000..e90a3d39cc --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupDestination.kt @@ -0,0 +1,10 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup + +sealed class IgnoredSetupDestination(val route: String) { + data object Label : IgnoredSetupDestination("label") + data object Location : IgnoredSetupDestination("location") + data object Type : IgnoredSetupDestination("type") + data object MatchedAlbums : IgnoredSetupDestination("matched_albums") + + operator fun invoke() = route +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupStage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupStage.kt new file mode 100644 index 0000000000..2bf0c1f532 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupStage.kt @@ -0,0 +1,8 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup + +enum class IgnoredSetupStage { + LABEL, + LOCATION, + TYPE, + MATCHED_ALBUMS +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupState.kt new file mode 100644 index 0000000000..ad5ad20451 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupState.kt @@ -0,0 +1,12 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup + +import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum + +data class IgnoredSetupState( + val label: String = "", + val location: Int = IgnoredAlbum.ALBUMS_ONLY, + val type: IgnoredType = IgnoredType.SELECTION(null), + val matchedAlbums: List = emptyList(), + val stage: IgnoredSetupStage = IgnoredSetupStage.LABEL +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt new file mode 100644 index 0000000000..14a66aa3f4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredSetupViewModel.kt @@ -0,0 +1,81 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.domain.use_case.MediaUseCases +import com.dot.gallery.feature_node.presentation.ignored.IgnoredState +import com.dot.gallery.feature_node.presentation.util.RepeatOnResume +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class IgnoredSetupViewModel @Inject constructor( + private val mediaUseCases: MediaUseCases +): ViewModel() { + + private val _uiState = MutableStateFlow(IgnoredSetupState()) + val uiState = _uiState.asStateFlow() + + var isLabelError by mutableStateOf(false) + + private val _ignoredState = MutableStateFlow(IgnoredState()) + val blacklistState = _ignoredState.asStateFlow() + + init { + getIgnoredAlbums() + } + + @SuppressLint("ComposableNaming") + @Composable + fun attachToLifecycle() { + RepeatOnResume { + getIgnoredAlbums() + } + } + + fun setLabel(label: String) { + _uiState.value = _uiState.value.copy(label = label) + isLabelError = label.isEmpty() + } + + fun setLocation(location: Int) { + _uiState.value = _uiState.value.copy(location = location) + } + + fun setType(type: IgnoredType) { + _uiState.value = _uiState.value.copy(type = type) + } + + fun setMatchedAlbums(matchedAlbums: List) { + _uiState.value = _uiState.value.copy(matchedAlbums = matchedAlbums) + } + + fun reset() { + _uiState.value = IgnoredSetupState() + } + + fun addToIgnored(ignoredAlbum: IgnoredAlbum) { + viewModelScope.launch { + mediaUseCases.blacklistUseCase.addToBlacklist(ignoredAlbum) + } + } + + private fun getIgnoredAlbums() { + viewModelScope.launch { + mediaUseCases.blacklistUseCase.blacklistedAlbums.collectLatest { + _ignoredState.emit(IgnoredState(it)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt new file mode 100644 index 0000000000..4048844027 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/IgnoredType.kt @@ -0,0 +1,10 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup + +import com.dot.gallery.feature_node.domain.model.Album + +sealed class IgnoredType { + + data class SELECTION(val selectedAlbum: Album?) : IgnoredType() + + data class REGEX(val regex: String) : IgnoredType() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/SelectAlbumSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/SelectAlbumSheet.kt similarity index 88% rename from app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/SelectAlbumSheet.kt rename to app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/SelectAlbumSheet.kt index 66f29ef77f..8db4e6e4b7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/SelectAlbumSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/SelectAlbumSheet.kt @@ -1,4 +1,4 @@ -package com.dot.gallery.feature_node.presentation.ignored +package com.dot.gallery.feature_node.presentation.ignored.setup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -31,7 +30,7 @@ import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import kotlinx.coroutines.launch @@ -40,7 +39,7 @@ import kotlinx.coroutines.launch @Composable fun SelectAlbumSheet( sheetState: AppBottomSheetState, - blacklistedAlbums: List, + ignoredAlbums: List, albumState: AlbumState, onSelect: (Album) -> Unit ) { @@ -53,13 +52,7 @@ fun SelectAlbumSheet( sheetState.hide() } }, - dragHandle = { DragHandle() }, - windowInsets = WindowInsets( - 0, - WindowInsets.statusBars.getTop(LocalDensity.current), - 0, - 0 - ) + dragHandle = { DragHandle() } ) { Column( modifier = Modifier @@ -103,7 +96,7 @@ fun SelectAlbumSheet( onSelect(album) } }, - isEnabled = blacklistedAlbums.firstOrNull { it.id == item.id } == null + isEnabled = ignoredAlbums.firstOrNull { it.id == item.id } == null ) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupConfirmationScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupConfirmationScreen.kt new file mode 100644 index 0000000000..8bee761cb2 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupConfirmationScreen.kt @@ -0,0 +1,127 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup.screens + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Checklist +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import com.dot.gallery.R +import com.dot.gallery.core.presentation.components.SetupWizard +import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.presentation.ignored.setup.IgnoredType + +@Composable +fun SetupConfirmationScreen( + onGoBack: () -> Unit, + onNext: () -> Unit, + location: Int, + type: IgnoredType, + matchedAlbums: List, +) { + SetupWizard( + title = stringResource(R.string.setup_confirmation_title), + subtitle = stringResource(R.string.setup_confirmation_subtitle), + icon = Icons.Outlined.Checklist, + bottomBar = { + OutlinedButton( + onClick = onGoBack + ) { + Text(text = stringResource(id = R.string.go_back)) + } + + Button( + onClick = onNext, + enabled = matchedAlbums.isNotEmpty() + ) { + Text(text = stringResource(R.string.apply)) + } + }, + content = { + val albumsString = stringResource(R.string.albums) + val timelineString = stringResource(R.string.timeline) + val bothString = stringResource(R.string.albums_and_timeline) + ConfirmationBlock( + title = stringResource(R.string.setup_confirmation_where), + subtitle = remember(location) { + when (location) { + IgnoredAlbum.ALBUMS_ONLY -> albumsString + IgnoredAlbum.TIMELINE_ONLY -> timelineString + IgnoredAlbum.ALBUMS_AND_TIMELINE -> bothString + else -> "Unknown Error" + } + } + ) + + ConfirmationBlock( + title = stringResource(R.string.setup_confirmation_who), + subtitle = remember(type) { + when (type) { + is IgnoredType.SELECTION -> type.selectedAlbum!!.label + is IgnoredType.REGEX -> type.regex + } + } + ) + + ConfirmationBlock( + title = stringResource(R.string.setup_confirmation_matched), + subtitle = remember(matchedAlbums) { + matchedAlbums.joinToString("\n", limit = 10) { it.label } + }, + extra = remember(matchedAlbums) { + if (matchedAlbums.size > 10) "+${matchedAlbums.size - 10}" else null + } + ) + } + ) +} + +@Composable +private fun ConfirmationBlock( + modifier: Modifier = Modifier, + title: String, + subtitle: String, + extra: String? = null, +) { + Text( + modifier = modifier.fillMaxWidth(), + text = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ).toSpanStyle() + ) { + appendLine(title) + } + + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ).toSpanStyle() + ) { + appendLine(subtitle) + } + + extra?.let { + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ).toSpanStyle() + ) { + appendLine(it) + } + } + } + ) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLabelScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLabelScreen.kt new file mode 100644 index 0000000000..7067c9330d --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLabelScreen.kt @@ -0,0 +1,85 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dot.gallery.R +import com.dot.gallery.core.presentation.components.SetupWizard + +@Composable +fun SetupLabelScreen( + onGoBack: () -> Unit, + onNext: () -> Unit, + isError: Boolean, + initialLabel: String, + onLabelChanged: (String) -> Unit +) { + var label by remember { mutableStateOf(initialLabel) } + LaunchedEffect(label) { + if (label != initialLabel) + onLabelChanged(label) + } + + SetupWizard( + title = stringResource(R.string.setup_label_title), + subtitle = stringResource(R.string.setup_lavel_subtitle), + icon = Icons.Outlined.VisibilityOff, + bottomBar = { + OutlinedButton( + onClick = onGoBack + ) { + Text(text = stringResource(id = R.string.action_cancel)) + } + + Button( + onClick = onNext, + enabled = !isError && label.isNotBlank() + ) { + Text(text = stringResource(R.string.get_started)) + } + }, + content = { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = label, + onValueChange = { label = it }, + label = { Text(stringResource(R.string.setup_label_input)) }, + placeholder = { Text(stringResource(R.string.setup_label_placeholder)) }, + isError = isError, + singleLine = true, + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large + ) + .padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.setup_label_info) + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLocationScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLocationScreen.kt new file mode 100644 index 0000000000..4c8802094f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupLocationScreen.kt @@ -0,0 +1,154 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup.screens + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.dot.gallery.R +import com.dot.gallery.core.presentation.components.SetupWizard +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.presentation.ignored.setup.IgnoredType + +@Composable +fun SetupLocationScreen( + onGoBack: () -> Unit, + onNext: () -> Unit, + isError: Boolean, + initialLocation: Int, + initialType: IgnoredType, + onLocationChanged: (Int) -> Unit, + onTypeChanged: (IgnoredType) -> Unit +) { + var location by remember { mutableIntStateOf(initialLocation) } + var type by remember { mutableStateOf(initialType) } + LaunchedEffect(location) { + onLocationChanged(location) + } + + LaunchedEffect(type) { + onTypeChanged(type) + } + + SetupWizard( + title = stringResource(R.string.setup_location_title), + subtitle = stringResource(R.string.setup_location_subtitle), + icon = Icons.Outlined.Settings, + bottomBar = { + OutlinedButton( + onClick = onGoBack + ) { + Text(text = stringResource(id = R.string.go_back)) + } + + Button( + onClick = onNext, + enabled = !isError + ) { + Text(text = stringResource(R.string.continue_string)) + } + }, + content = { + Text( + buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.bodyLarge.toSpanStyle() + ) { + append(stringResource(R.string.setup_location_location_title)) + } + appendLine() + withStyle( + style = MaterialTheme.typography.bodySmall.toSpanStyle() + ) { + append(stringResource(R.string.setup_location_location_subtitle)) + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val options = mapOf( + stringResource(R.string.setup_location_options_albums) to IgnoredAlbum.ALBUMS_ONLY, + stringResource(R.string.setup_location_options_timeline) to IgnoredAlbum.TIMELINE_ONLY, + stringResource(R.string.setup_location_options_both) to IgnoredAlbum.ALBUMS_AND_TIMELINE, + ) + + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth() + ){ + options.onEachIndexed { index, (option, optLocation) -> + SegmentedButton( + selected = location == options[option], + onClick = { location = optLocation }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size) + ) { + Text(text = option) + } + } + } + + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.bodyLarge.toSpanStyle() + ) { + append(stringResource(R.string.setup_location_type_title)) + } + appendLine() + withStyle( + style = MaterialTheme.typography.bodySmall.toSpanStyle() + ) { + append(stringResource(R.string.setup_location_type_subtitle)) + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val types = mapOf( + stringResource(R.string.setup_location_types_selection) to IgnoredType.SELECTION::class, + stringResource(R.string.setup_location_types_regex) to IgnoredType.REGEX::class, + ) + + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth() + ) { + types.onEachIndexed { index, (title, titleType) -> + SegmentedButton( + selected = titleType.isInstance(type), + onClick = { type = when (titleType) { + IgnoredType.SELECTION::class -> IgnoredType.SELECTION(null) + IgnoredType.REGEX::class -> IgnoredType.REGEX("") + else -> IgnoredType.SELECTION(null) + } }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = types.size) + ) { + Text(text = title) + } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeRegexScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeRegexScreen.kt new file mode 100644 index 0000000000..3a6b41a150 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeRegexScreen.kt @@ -0,0 +1,179 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dot.gallery.R +import com.dot.gallery.core.presentation.components.SetupWizard +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.ui.core.Icons +import com.dot.gallery.ui.core.icons.RegularExpression + +@Composable +fun SetupTypeRegexScreen( + onGoBack: () -> Unit, + onNext: () -> Unit, + initialRegex: String, + ignoredAlbums: List, + onRegexChanged: (String) -> Unit, +) { + var regex by remember { mutableStateOf(initialRegex) } + var error by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + val invalidRegex = stringResource(R.string.setup_type_regex_error) + val alreadyUsedRegex = stringResource(R.string.setup_type_regex_error_second) + LaunchedEffect(regex) { + val validRegex = try { + regex.toRegex() + true + } catch (e: Exception) { + false + } + error = !validRegex + if (error) errorMessage = invalidRegex + error = ignoredAlbums.any { it.wildcard == regex } + if (error) errorMessage = alreadyUsedRegex + + if (!error) { + errorMessage = "" + onRegexChanged(regex) + } + + } + + SetupWizard( + title = stringResource(R.string.setup_type_regex_title), + subtitle = stringResource(R.string.setup_type_regex_subtitle), + icon = Icons.RegularExpression, + bottomBar = { + OutlinedButton( + onClick = onGoBack + ) { + Text(text = stringResource(id = R.string.go_back)) + } + + Button( + onClick = onNext, + enabled = regex.isNotEmpty() + ) { + Text(text = stringResource(R.string.continue_string)) + } + }, + content = { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = regex, + onValueChange = { regex = it }, + label = { Text(stringResource(R.string.setup_type_regex_label)) }, + placeholder = { Text(stringResource(R.string.setup_type_regex_label)) }, + supportingText = if (error) {{ + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + }} else null, + isError = error, + singleLine = true, + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large + ) + .padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.setup_type_regex_summary) + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp), + text = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.bodyMedium.toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_example_title)) + } + withStyle( + style = MaterialTheme.typography.bodySmall.toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_example_first_title)) + } + + withStyle( + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ).toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_first_subtitle)) + } + + appendLine() + + withStyle( + style = MaterialTheme.typography.bodySmall.toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_example_second_title)) + } + + withStyle( + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ).toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_example_second_subtitle)) + } + + appendLine() + + withStyle( + style = MaterialTheme.typography.bodySmall.toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_example_third_title)) + } + + withStyle( + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ).toSpanStyle() + ) { + appendLine(stringResource(R.string.setup_type_regex_example_third_subtitle)) + } + } + ) + } + ) +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeSelectionScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeSelectionScreen.kt new file mode 100644 index 0000000000..120239cd19 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/screens/SetupTypeSelectionScreen.kt @@ -0,0 +1,240 @@ +package com.dot.gallery.feature_node.presentation.ignored.setup.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.PhotoAlbum +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.CornerRounding +import androidx.graphics.shapes.Morph +import androidx.graphics.shapes.RoundedPolygon +import androidx.graphics.shapes.star +import androidx.graphics.shapes.toPath +import com.dot.gallery.R +import com.dot.gallery.core.AlbumState +import com.dot.gallery.core.presentation.components.SetupWizard +import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.domain.model.IgnoredAlbum +import com.dot.gallery.feature_node.presentation.ignored.setup.SelectAlbumSheet +import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.github.panpf.sketch.AsyncImage +import kotlinx.coroutines.launch + +@Composable +fun SetupTypeSelectionScreen( + onGoBack: () -> Unit, + onNext: () -> Unit, + initialAlbum: Album?, + ignoredAlbums: List, + albumsState: AlbumState, + onAlbumChanged: (Album?) -> Unit, +) { + var album by remember { mutableStateOf(initialAlbum) } + LaunchedEffect(album) { + onAlbumChanged(album) + } + + SetupWizard( + title = stringResource(R.string.setup_type_selection_title), + subtitle = stringResource(R.string.setup_type_selection_subtitle), + icon = Icons.Outlined.PhotoAlbum, + bottomBar = { + OutlinedButton( + onClick = onGoBack + ) { + Text(text = stringResource(id = R.string.go_back)) + } + + Button( + onClick = onNext, + enabled = album != null + ) { + Text(text = stringResource(R.string.continue_string)) + } + }, + content = { + val shapeA = remember { + RoundedPolygon( + 12, + rounding = CornerRounding(0.4f) + ) + } + val shapeB = remember { + RoundedPolygon.star( + 12, + rounding = CornerRounding(0.2f) + ) + } + val morph = remember { + Morph(shapeA, shapeB) + } + val infiniteTransition = rememberInfiniteTransition("infinite outline movement") + val animatedProgress = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "animatedMorphProgress" + ) + val animatedRotation = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + tween(6000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "animatedMorphProgress" + ) + + val pickAlbumState = rememberAppBottomSheetState() + val scope = rememberCoroutineScope() + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(200.dp) + .clip( + CustomRotatingMorphShape( + morph, + animatedProgress.value, + animatedRotation.value + ) + ) + .background(color = MaterialTheme.colorScheme.primaryContainer) + .clickable { + scope.launch { + pickAlbumState.show() + } + } + ) { + this@Column.AnimatedVisibility(album != null) { + AsyncImage( + uri = album!!.uri.toString(), + contentDescription = null, + contentScale = ContentScale.Crop, + clipToBounds = true, + modifier = Modifier.fillMaxSize() + ) + } + + this@Column.AnimatedVisibility(album == null) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.setup_type_selection_add_album), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.fillMaxSize().padding(64.dp) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + val pickAlbumTitle = stringResource(R.string.setup_type_selection_pick_album) + val albumTitle = remember(album) { + if (album == null) pickAlbumTitle else album!!.label + } + + Text( + buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.headlineSmall.toSpanStyle() + ) { + append(albumTitle) + } + + if (album != null) { + appendLine() + withStyle( + style = MaterialTheme.typography.bodyMedium.toSpanStyle() + ) { + append(stringResource(R.string.s_items, album!!.count)) + } + } + }, + textAlign = TextAlign.Center, + ) + } + + SelectAlbumSheet( + sheetState = pickAlbumState, + ignoredAlbums = ignoredAlbums, + albumState = albumsState, + ) { pickedAlbum -> + album = pickedAlbum + onAlbumChanged(pickedAlbum) + } + } + ) +} + +private class CustomRotatingMorphShape( + private val morph: Morph, + private val percentage: Float, + private val rotation: Float +) : Shape { + + private val matrix = Matrix() + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f + // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y. + matrix.scale(size.width / 2f, size.height / 2f) + matrix.translate(1f, 1f) + matrix.rotateZ(rotation) + + val path = morph.toPath(progress = percentage).asComposePath() + path.transform(matrix) + + return Outline.Generic(path) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt index c5bc51c27c..948e403578 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryScreen.kt @@ -14,9 +14,9 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.EditNote import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu @@ -193,21 +193,20 @@ fun LibraryScreen( Button( modifier = Modifier.weight(1f), onClick = { - //navigate(Screen.FavoriteScreen.route) + navigate(Screen.IgnoredScreen()) }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiary, contentColor = MaterialTheme.colorScheme.onTertiary - ), - enabled = false + ) ) { Icon( - imageVector = Icons.Outlined.EditNote, - contentDescription = stringResource(R.string.tools) + imageVector = Icons.Outlined.VisibilityOff, + contentDescription = stringResource(R.string.ignored_albums) ) Spacer(modifier = Modifier.size(8.dp)) Text( - text = stringResource(id = R.string.tools) + text = stringResource(id = R.string.ignored_albums) ) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt index 122a95ce54..f316084df7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/main/MainActivity.kt @@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,8 +27,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController -import com.dot.gallery.core.Settings import com.dot.gallery.core.Settings.Misc.getSecureMode +import com.dot.gallery.core.Settings.Misc.rememberForceTheme +import com.dot.gallery.core.Settings.Misc.rememberIsDarkMode import com.dot.gallery.core.presentation.components.AppBarContainer import com.dot.gallery.core.presentation.components.NavigationComp import com.dot.gallery.feature_node.presentation.util.toggleOrientation @@ -48,19 +49,19 @@ class MainActivity : AppCompatActivity() { enforceSecureFlag() enableEdgeToEdge() setContent { - val windowSizeClass = calculateWindowSizeClass(this) GalleryTheme { + val windowSizeClass = calculateWindowSizeClass(this) val navController = rememberNavController() val isScrolling = remember { mutableStateOf(false) } val bottomBarState = rememberSaveable { mutableStateOf(true) } val systemBarFollowThemeState = rememberSaveable { mutableStateOf(true) } - val forcedTheme by Settings.Misc.rememberForceTheme() - val localDarkTheme by Settings.Misc.rememberIsDarkMode() + val forcedTheme by rememberForceTheme() + val localDarkTheme by rememberIsDarkMode() val systemDarkTheme = isSystemInDarkTheme() - val darkTheme = remember(forcedTheme, localDarkTheme, systemDarkTheme) { - if (forcedTheme) localDarkTheme else systemDarkTheme + val darkTheme by remember(forcedTheme, localDarkTheme, systemDarkTheme) { + mutableStateOf(if (forcedTheme) localDarkTheme else systemDarkTheme) } - DisposableEffect(darkTheme, systemBarFollowThemeState.value) { + LaunchedEffect(darkTheme, systemBarFollowThemeState.value) { enableEdgeToEdge( statusBarStyle = SystemBarStyle.auto( Color.TRANSPARENT, @@ -71,7 +72,6 @@ class MainActivity : AppCompatActivity() { Color.TRANSPARENT, ) { darkTheme || !systemBarFollowThemeState.value } ) - onDispose {} } Scaffold( modifier = Modifier.fillMaxSize(), @@ -79,9 +79,9 @@ class MainActivity : AppCompatActivity() { AppBarContainer( navController = navController, paddingValues = paddingValues, - bottomBarState = bottomBarState, + bottomBarState = bottomBarState.value, windowSizeClass = windowSizeClass, - isScrolling = isScrolling + isScrolling = isScrolling.value ) { NavigationComp( navController = navController, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt index 15a3b6dc9b..d6effad18a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/MediaViewScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -41,7 +42,6 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.core.AlbumState import com.dot.gallery.core.Constants import com.dot.gallery.core.Constants.Animation.enterAnimation @@ -63,9 +63,9 @@ import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetStat import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController import com.dot.gallery.feature_node.presentation.util.toggleSystemBars import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +@Stable @OptIn(ExperimentalFoundationApi::class) @Composable fun MediaViewScreen( @@ -75,22 +75,20 @@ fun MediaViewScreen( isStandalone: Boolean = false, mediaId: Long, target: String? = null, - mediaState: StateFlow, - albumsState: StateFlow, + mediaState: MediaState, + albumsState: AlbumState, handler: MediaHandleUseCase, vaults: List, addMedia: (Vault, Media) -> Unit ) { var runtimeMediaId by rememberSaveable(mediaId) { mutableLongStateOf(mediaId) } - val state by mediaState.collectAsStateWithLifecycle() - val albumState by albumsState.collectAsStateWithLifecycle() val initialPage = rememberSaveable(runtimeMediaId) { - state.media.indexOfFirst { it.id == runtimeMediaId }.coerceAtLeast(0) + mediaState.media.indexOfFirst { it.id == runtimeMediaId }.coerceAtLeast(0) } val pagerState = rememberPagerState( initialPage = initialPage, initialPageOffsetFraction = 0f, - pageCount = state.media::size + pageCount = mediaState.media::size ) val bottomSheetState = rememberAppBottomSheetState() @@ -101,14 +99,17 @@ fun MediaViewScreen( val windowInsetsController = rememberWindowInsetsController() var lastIndex by remember { mutableIntStateOf(-1) } - val updateContent: (Int) -> Unit = { page -> - if (state.media.isNotEmpty()) { - val index = if (page == -1) 0 else page - if (lastIndex != -1) - runtimeMediaId = state.media[lastIndex.coerceAtMost(state.media.size - 1)].id - currentDate.value = state.media[index].timestamp.getDate(HEADER_DATE_FORMAT) - currentMedia.value = state.media[index] - } else if (!isStandalone) navigateUp() + val updateContent: (Int) -> Unit = remember { + { page -> + if (mediaState.media.isNotEmpty()) { + val index = if (page == -1) 0 else page + if (lastIndex != -1) + runtimeMediaId = + mediaState.media[lastIndex.coerceAtMost(mediaState.media.size - 1)].id + currentDate.value = mediaState.media[index].timestamp.getDate(HEADER_DATE_FORMAT) + currentMedia.value = mediaState.media[index] + } else if (!isStandalone) navigateUp() + } } val scope = rememberCoroutineScope() @@ -116,7 +117,7 @@ fun MediaViewScreen( currentMedia.value?.trashed == 0 && !(currentMedia.value?.readUriOnly ?: false) } - LaunchedEffect(pagerState, state.media) { + LaunchedEffect(pagerState, mediaState.media) { snapshotFlow { pagerState.currentPage }.collect { page -> updateContent(page) } @@ -147,14 +148,14 @@ fun MediaViewScreen( state = pagerState, flingBehavior = PagerDefaults.flingBehavior( state = pagerState, - lowVelocityAnimationSpec = tween( + snapAnimationSpec = tween( easing = FastOutLinearInEasing, durationMillis = DEFAULT_LOW_VELOCITY_SWIPE_DURATION ) ), key = { index -> - if (state.media.isNotEmpty()) { - state.media[index.coerceIn(0 until state.media.size)].id + if (mediaState.media.isNotEmpty()) { + mediaState.media[index.coerceIn(0 until mediaState.media.size)].id } else "empty" }, pageSpacing = 16.dp, @@ -166,9 +167,12 @@ fun MediaViewScreen( playWhenReady = currentPage == index } } + val media = remember(index, mediaState) { + mediaState.media[index] + } MediaPreviewComponent( - media = state.media[index], + media = media, uiEnabled = showUI.value, playWhenReady = playWhenReady, onItemClick = { @@ -196,8 +200,8 @@ fun MediaViewScreen( indication = null, onDoubleClick = { scope.launch { - currentTime.value += 10 * 1000 - player.seekTo(currentTime.value) + currentTime.longValue += 10 * 1000 + player.seekTo(currentTime.longValue) delay(100) player.play() } @@ -222,8 +226,8 @@ fun MediaViewScreen( indication = null, onDoubleClick = { scope.launch { - currentTime.value -= 10 * 1000 - player.seekTo(currentTime.value) + currentTime.longValue -= 10 * 1000 + player.seekTo(currentTime.longValue) delay(100) player.play() } @@ -258,13 +262,20 @@ fun MediaViewScreen( MediaViewAppBar( showUI = showUI.value, showInfo = showInfo, - showDate = currentMedia.value?.timestamp != 0L, + showDate = remember(currentMedia) { + currentMedia.value?.timestamp != 0L + }, currentDate = currentDate.value, bottomSheetState = bottomSheetState, paddingValues = paddingValues, onGoBack = navigateUp ) - if (target == TARGET_TRASH) { + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = target == TARGET_TRASH, + enter = enterAnimation, + exit = exitAnimation + ) { TrashedViewBottomBar( handler = handler, showUI = showUI.value, @@ -274,7 +285,13 @@ fun MediaViewScreen( ) { lastIndex = it } - } else { + } + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = target != TARGET_TRASH, + enter = enterAnimation, + exit = exitAnimation + ) { MediaViewBottomBar( showDeleteButton = remember(currentMedia.value) { currentMedia.value?.readUriOnly == false @@ -284,7 +301,7 @@ fun MediaViewScreen( showUI = showUI.value, paddingValues = paddingValues, currentMedia = currentMedia.value, - albumsState = albumState, + albumsState = albumsState, currentIndex = pagerState.currentPage, addMedia = addMedia, vaults = vaults diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/AppBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/AppBar.kt index a3a57494db..fd464e7975 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/AppBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/AppBar.kt @@ -21,6 +21,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,10 +33,14 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.dot.gallery.core.Constants +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.ui.theme.BlackScrim import kotlinx.coroutines.launch +@Stable +@NonRestartableComposable @Composable fun MediaViewAppBar( showUI: Boolean, @@ -48,8 +54,8 @@ fun MediaViewAppBar( val scope = rememberCoroutineScope() AnimatedVisibility( visible = showUI, - enter = Constants.Animation.enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), - exit = Constants.Animation.exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION) + enter = enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), + exit = exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION) ) { Row( modifier = Modifier @@ -77,7 +83,11 @@ fun MediaViewAppBar( Row( verticalAlignment = Alignment.CenterVertically ) { - if (showDate) { + AnimatedVisibility( + visible = showDate, + enter = enterAnimation, + exit = exitAnimation + ) { Text( text = currentDate.uppercase(), modifier = Modifier, @@ -87,7 +97,11 @@ fun MediaViewAppBar( textAlign = TextAlign.End ) } - if (showInfo) { + AnimatedVisibility( + visible = showUI, + enter = enterAnimation, + exit = exitAnimation + ) { IconButton( onClick = { scope.launch { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/BottomBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/BottomBar.kt index 7afc4554fd..b8f3b9e802 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/BottomBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/BottomBar.kt @@ -77,7 +77,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.AlbumState @@ -115,6 +114,7 @@ import com.dot.gallery.feature_node.presentation.util.shareMedia import com.dot.gallery.feature_node.presentation.vault.components.SelectVaultSheet import com.dot.gallery.ui.theme.BlackScrim import com.dot.gallery.ui.theme.Shapes +import com.github.panpf.sketch.AsyncImage import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -215,7 +215,7 @@ fun MediaInfoBottomSheet( dragHandle = { DragHandle() }, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetState = state.sheetState, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { BackHandler { scope.launch { @@ -430,7 +430,7 @@ fun MediaInfoMapPreview(exifMetadata: ExifMetadata) { .background(MaterialTheme.colorScheme.surface) ) { AsyncImage( - model = MapBoxURL( + uri = MapBoxURL( latitude = lat, longitude = long, darkTheme = isSystemInDarkTheme() @@ -476,7 +476,9 @@ private fun MediaViewInfoActions( // Share Component ShareButton(media, followTheme = true) // Hide - HideButton(media, vaults = vaults, addMedia = addMedia, followTheme = true) + if (!media.isVideo) { + HideButton(media, vaults = vaults, addMedia = addMedia, followTheme = true) + } // Use as or Open With OpenAsButton(media, followTheme = true) // Copy @@ -624,10 +626,15 @@ private fun FavoriteButton( followTheme: Boolean = false ) { val scope = rememberCoroutineScope() - val result = rememberActivityResult() - val favoriteIcon by remember(media) { + var lastFavorite = remember(media) { media.isFavorite } + val result = rememberActivityResult( + onResultOk = { + lastFavorite = !lastFavorite + } + ) + val favoriteIcon by remember(lastFavorite) { mutableStateOf( - if (media.isFavorite) + if (lastFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt index 2f4d65f84b..e2687fbca6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/MediaPreviewComponent.kt @@ -5,16 +5,23 @@ package com.dot.gallery.feature_node.presentation.mediaview.components.media +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.MutableState +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.media3.exoplayer.ExoPlayer import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayer +@Stable +@NonRestartableComposable @Composable fun MediaPreviewComponent( media: Media, @@ -24,17 +31,26 @@ fun MediaPreviewComponent( videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, ) { Box( - modifier = Modifier - .fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { - if (media.isVideo) { + AnimatedVisibility( + visible = media.isVideo, + enter = fadeIn(), + exit = fadeOut() + ) { VideoPlayer( media = media, playWhenReady = playWhenReady, videoController = videoController, onItemClick = onItemClick ) - } else { + } + + AnimatedVisibility( + visible = !media.isVideo, + enter = fadeIn(), + exit = fadeOut() + ) { ZoomablePagerImage( media = media, uiEnabled = uiEnabled, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt index f5d3f3c169..4fddf4a0f7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/media/ZoomablePagerImage.kt @@ -6,109 +6,84 @@ package com.dot.gallery.feature_node.presentation.mediaview.components.media import android.os.Build +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp -import coil3.compose.LocalPlatformContext -import coil3.compose.rememberAsyncImagePainter -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Scale import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION import com.dot.gallery.core.Settings import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus import com.dot.gallery.feature_node.domain.model.Media -import me.saket.telephoto.zoomable.DoubleClickToZoomListener -import me.saket.telephoto.zoomable.ZoomSpec -import me.saket.telephoto.zoomable.ZoomableImage -import me.saket.telephoto.zoomable.ZoomableImageSource -import me.saket.telephoto.zoomable.coil3.coil3 -import me.saket.telephoto.zoomable.rememberZoomableImageState -import me.saket.telephoto.zoomable.rememberZoomableState -import net.engawapg.lib.zoomable.rememberZoomState +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.request.ComposableImageRequest +import com.github.panpf.sketch.util.Size +import com.github.panpf.zoomimage.SketchZoomAsyncImage +import com.github.panpf.zoomimage.rememberSketchZoomState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +@Stable +@NonRestartableComposable @Composable fun ZoomablePagerImage( modifier: Modifier = Modifier, media: Media, uiEnabled: Boolean, - maxScale: Float = 10f, onItemClick: () -> Unit ) { - val zoomState = rememberZoomState( - maxScale = maxScale, - ) - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(media.uri) - .memoryCachePolicy(CachePolicy.ENABLED) - .placeholderMemoryCacheKey(media.toString()) - .scale(Scale.FILL) - .build(), - contentScale = ContentScale.Fit, - filterQuality = FilterQuality.None, - onSuccess = { - zoomState.setContentSize(it.painter.intrinsicSize) + ProvideBatteryStatus { + val allowBlur by Settings.Misc.rememberAllowBlur() + val isPowerSavingMode = LocalBatteryStatus.current.isPowerSavingMode + AnimatedVisibility(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && allowBlur && !isPowerSavingMode) { + val blurAlpha by animateFloatAsState( + animationSpec = tween(DEFAULT_TOP_BAR_ANIMATION_DURATION), + targetValue = if (uiEnabled) 0.7f else 0f, + label = "blurAlpha" + ) + AsyncImage( + modifier = Modifier + .fillMaxSize() + .alpha(blurAlpha) + .blur(100.dp), + request = ComposableImageRequest(media.uri.toString()) { + size(Size.parseSize("600x600")) + }, + contentDescription = null, + filterQuality = FilterQuality.None, + contentScale = ContentScale.Crop + ) } - ) - val zoomableState = rememberZoomableState( - zoomSpec = ZoomSpec( - maxZoomFactor = maxScale - ) - ) - val state = rememberZoomableImageState( - zoomableState = zoomableState - ) - - Box(modifier = Modifier.fillMaxSize()) { - ProvideBatteryStatus { - val allowBlur by Settings.Misc.rememberAllowBlur() - val isPowerSavingMode = LocalBatteryStatus.current.isPowerSavingMode - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && allowBlur && !isPowerSavingMode) { - val blurAlpha by animateFloatAsState( - animationSpec = tween(DEFAULT_TOP_BAR_ANIMATION_DURATION), - targetValue = if (uiEnabled) 0.7f else 0f, - label = "blurAlpha" - ) - Image( - modifier = Modifier - .fillMaxSize() - .alpha(blurAlpha) - .blur(100.dp), - painter = painter, - contentDescription = null, - contentScale = ContentScale.Crop - ) - } + } + val zoomState = rememberSketchZoomState() + val scope = rememberCoroutineScope() + LaunchedEffect(LocalConfiguration.current) { + scope.launch { + delay(100) + zoomState.zoomable.reset("alignmentChanged") } - - ZoomableImage( - modifier = modifier.fillMaxSize(), - onClick = { onItemClick() }, - state = state, - image = ZoomableImageSource.coil3( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(media.uri) - .scale(Scale.FILL) - .placeholderMemoryCacheKey(media.toString()) - .build() - ), - contentScale = ContentScale.Fit, - contentDescription = media.label, - onDoubleClick = DoubleClickToZoomListener.cycle(3f) - ) } - + SketchZoomAsyncImage( + zoomState = zoomState, + modifier = modifier.fillMaxSize(), + onTap = { onItemClick() }, + alignment = Alignment.Center, + uri = media.uri.toString(), + contentDescription = media.label + ) } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt index d1f62202c3..6958a0ba68 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayer.kt @@ -10,13 +10,14 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.MutableState +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -24,7 +25,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle @@ -42,8 +42,10 @@ import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds import io.sanghun.compose.video.VideoPlayer as SanghunComposeVideoVideoPlayer +@Stable @OptIn(ExperimentalFoundationApi::class) @androidx.annotation.OptIn(UnstableApi::class) +@NonRestartableComposable @Composable fun VideoPlayer( media: Media, @@ -73,94 +75,91 @@ fun VideoPlayer( mutableStateOf(null) } - Box { - var showPlayer by remember { - mutableStateOf(false) - } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - showPlayer = true - } - if (event == Lifecycle.Event.ON_PAUSE) { - showPlayer = false - } + var showPlayer by remember { + mutableStateOf(false) + } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + showPlayer = true } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) + if (event == Lifecycle.Event.ON_PAUSE) { + showPlayer = false } } - LaunchedEffect(showPlayer) { - if (showPlayer) { - delay(100) - exoPlayer?.playWhenReady = true - exoPlayer?.seekTo(currentTime.longValue) - } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) } - + } + LaunchedEffect(showPlayer) { if (showPlayer) { - SanghunComposeVideoVideoPlayer( - mediaItems = listOf( - VideoPlayerMediaItem.StorageMediaItem( - storageUri = media.uri, - mimeType = media.mimeType - ) - ), - handleLifecycle = true, - autoPlay = true, - usePlayerController = false, - enablePip = false, - handleAudioFocus = true, - repeatMode = RepeatMode.ONE, - playerInstance = { - exoPlayer = this - addListener( - object : Player.Listener { - override fun onEvents(player: Player, events: Player.Events) { - totalDuration = duration.coerceAtLeast(0L) - lastPlayingState = isPlaying.value - isPlaying.value = player.isPlaying - } - } - ) - }, - modifier = Modifier - .fillMaxSize() - .align(Alignment.Center) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onItemClick, - ), - ) + delay(100) + exoPlayer?.playWhenReady = true + exoPlayer?.seekTo(currentTime.longValue) } - AnimatedVisibility( - visible = exoPlayer != null, - enter = enterAnimation, - exit = exitAnimation - ) { - LaunchedEffect(isPlaying.value) { - exoPlayer!!.playWhenReady = isPlaying.value - } - if (isPlaying.value) { - LaunchedEffect(Unit) { - while (true) { - bufferedPercentage = exoPlayer!!.bufferedPercentage - currentTime.longValue = exoPlayer!!.currentPosition - delay(1.seconds / 30) + } + + if (showPlayer) { + SanghunComposeVideoVideoPlayer( + mediaItems = listOf( + VideoPlayerMediaItem.StorageMediaItem( + storageUri = media.uri, + mimeType = media.mimeType + ) + ), + handleLifecycle = true, + autoPlay = true, + usePlayerController = false, + enablePip = false, + handleAudioFocus = true, + repeatMode = RepeatMode.ONE, + playerInstance = { + exoPlayer = this + addListener( + object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + totalDuration = duration.coerceAtLeast(0L) + lastPlayingState = isPlaying.value + isPlaying.value = player.isPlaying + } } + ) + }, + modifier = Modifier + .fillMaxSize() + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onItemClick, + ), + ) + } + AnimatedVisibility( + visible = exoPlayer != null, + enter = enterAnimation, + exit = exitAnimation + ) { + LaunchedEffect(isPlaying.value) { + exoPlayer!!.playWhenReady = isPlaying.value + } + if (isPlaying.value) { + LaunchedEffect(Unit) { + while (true) { + bufferedPercentage = exoPlayer!!.bufferedPercentage + currentTime.longValue = exoPlayer!!.currentPosition + delay(1.seconds / 30) } } - videoController( - exoPlayer!!, - isPlaying, - currentTime, - totalDuration, - bufferedPercentage, - frameRate - ) } + videoController( + exoPlayer!!, + isPlaying, + currentTime, + totalDuration, + bufferedPercentage, + frameRate + ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt index d1db9a2f58..87da669282 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/video/VideoPlayerController.kt @@ -7,6 +7,7 @@ package com.dot.gallery.feature_node.presentation.mediaview.components.video import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.VolumeMute import androidx.compose.material.icons.automirrored.outlined.VolumeUp @@ -26,6 +28,7 @@ import androidx.compose.material.icons.outlined.ScreenRotation import androidx.compose.material.icons.outlined.Speed import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,6 +49,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration @@ -53,6 +57,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.media3.exoplayer.ExoPlayer import com.dot.gallery.R @@ -60,7 +65,9 @@ import com.dot.gallery.feature_node.domain.model.PlaybackSpeed import com.dot.gallery.feature_node.presentation.util.formatMinSec import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.math.max +@OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoPlayerController( paddingValues: PaddingValues, @@ -181,6 +188,7 @@ fun VideoPlayerController( ) ) } + var sliderValue by rememberSaveable(currentTime.longValue) { mutableFloatStateOf(currentTime.longValue.toFloat()) } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -188,46 +196,89 @@ fun VideoPlayerController( ) { Text( modifier = Modifier.width(52.dp), - text = currentTime.value.formatMinSec(), + text = max(sliderValue, currentTime.longValue.toFloat()).toLong().formatMinSec(), fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium, color = Color.White, textAlign = TextAlign.Center ) Box(Modifier.weight(1f)) { + val disabledColors = SliderDefaults.colors( + disabledThumbColor = Color.Transparent, + disabledInactiveTrackColor = Color.DarkGray.copy(alpha = 0.4f), + disabledActiveTrackColor = Color.Gray.copy(alpha = 0.8f), + disabledActiveTickColor = Color.Transparent + ) Slider( modifier = Modifier.fillMaxWidth(), value = buffer.toFloat(), enabled = false, onValueChange = {}, + thumb = { + SliderDefaults.Thumb( + interactionSource = remember { MutableInteractionSource() }, + thumbSize = DpSize(0.dp, 0.dp), + colors = disabledColors, + enabled = false + ) + }, + track = { + SliderDefaults.Track( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(100)), + sliderState = it, + colors = disabledColors, + drawStopIndicator = null, + drawTick = { _, _ -> }, + enabled = false, + thumbTrackGapSize = 0.dp + ) + }, valueRange = 0f..100f, - colors = - SliderDefaults.colors( - disabledThumbColor = Color.Transparent, - disabledInactiveTrackColor = Color.DarkGray.copy(alpha = 0.4f), - disabledActiveTrackColor = Color.Gray - ) + colors = disabledColors + ) + val activeColors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White, + activeTickColor = Color.Transparent, + inactiveTrackColor = Color.Transparent ) Slider( modifier = Modifier.fillMaxWidth(), - value = currentTime.longValue.toFloat(), + value = sliderValue, + thumb = { + SliderDefaults.Thumb( + interactionSource = remember { MutableInteractionSource() }, + thumbSize = DpSize(0.dp, 0.dp), + colors = activeColors, + ) + }, + track = { + SliderDefaults.Track( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(100)), + sliderState = it, + colors = activeColors, + drawStopIndicator = null, + drawTick = { _, _ -> }, + thumbTrackGapSize = 0.dp + ) + }, onValueChange = { + if (player.isPlaying) { + player.pause() + } + sliderValue = it + }, + onValueChangeFinished = { scope.launch { - if (player.currentPosition != it.toLong()) { - player.seekTo(it.toLong()) + if (player.currentPosition != sliderValue.toLong()) { + player.seekTo(sliderValue.toLong()) delay(50) player.play() } } }, valueRange = 0f..totalTime.toFloat(), - colors = - SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color.White, - activeTickColor = Color.White, - inactiveTrackColor = Color.Transparent - ) + colors = activeColors ) } Text( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt index 7a442e3560..1a6c9dedd3 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerActivity.kt @@ -31,15 +31,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.setSingletonImageLoaderFactory import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.picker.PickerActivity.Companion.EXPORT_AS_MEDIA import com.dot.gallery.feature_node.presentation.picker.PickerActivity.Companion.MEDIA_LIST import com.dot.gallery.feature_node.presentation.picker.components.PickerScreen -import com.dot.gallery.feature_node.presentation.util.newImageLoader import com.dot.gallery.ui.theme.GalleryTheme import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -77,7 +74,6 @@ class PickerActivity : ComponentActivity() { intent.getBooleanExtra(EXPORT_AS_MEDIA, false) } - @OptIn(ExperimentalCoilApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -95,7 +91,6 @@ class PickerActivity : ComponentActivity() { else getString(R.string.photos_and_videos) } setContent { - setSingletonImageLoaderFactory(::newImageLoader) GalleryTheme { PickerRootScreen(title, type.allowedMedia, allowMultiple) } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt index 5fdf43d90c..0f30f3c7f0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerMediaScreen.kt @@ -103,7 +103,7 @@ fun PickerMediaScreen( is MediaItem.MediaViewItem -> { val selectionState = remember { mutableStateOf(true) } MediaImage( - modifier = Modifier.animateItemPlacement(), + modifier = Modifier.animateItem(), media = item.media, selectionState = selectionState, selectedMedia = selectedMedia, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/MainSearchBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/MainSearchBar.kt index f6e42ee29b..1bb09c722a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/MainSearchBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/search/MainSearchBar.kt @@ -9,6 +9,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -57,6 +58,7 @@ import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.cellsList +import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar import com.dot.gallery.core.Settings.Misc.rememberGridSize import com.dot.gallery.core.Settings.Search.rememberSearchHistory import com.dot.gallery.core.presentation.components.LoadingMedia @@ -103,6 +105,10 @@ fun MainSearchBar( toggleNavbar(!activeState.value) } } + val hideSearchBarSetting by rememberAutoHideSearchBar() + val shouldHide by remember(isScrolling.value, hideSearchBarSetting) { + mutableStateOf(if (hideSearchBarSetting) isScrolling.value else false) + } Box( modifier = Modifier @@ -116,144 +122,158 @@ fun MainSearchBar( * It is not yet possible because of the material3 compose limitations */ val searchBarAlpha by animateFloatAsState( - targetValue = remember(isScrolling.value) { if (isScrolling.value) 0f else 1f }, + targetValue = remember(shouldHide) { if (shouldHide) 0f else 1f }, label = "searchBarAlpha" ) + val onActiveChange: (Boolean) -> Unit = { activeState.value = it } + val colors = SearchBarDefaults.colors( + dividerColor = Color.Transparent, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + /** + * Searched content + */ SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { + query = it + if (it != mediaViewModel.lastQuery.value && mediaViewModel.lastQuery.value.isNotEmpty()) + mediaViewModel.clearQuery() + }, + onSearch = { + if (it.isNotEmpty()) + historySet = + historySet.toMutableSet().apply { add("${System.currentTimeMillis()}/$it") } + mediaViewModel.queryMedia(it) + }, + expanded = activeState.value, + onExpandedChange = onActiveChange, + enabled = (selectionState == null || !selectionState.value) && !shouldHide, + placeholder = { + Text(text = stringResource(id = R.string.searchbar_title)) + }, + leadingIcon = { + IconButton( + enabled = selectionState == null, + onClick = { + scope.launch { + activeState.value = !activeState.value + if (query.isNotEmpty()) query = "" + mediaViewModel.clearQuery() + } + }) { + val leadingIcon = if (activeState.value) + Icons.AutoMirrored.Outlined.ArrowBack else Icons.Outlined.Search + Icon( + imageVector = leadingIcon, + modifier = Modifier.fillMaxHeight(), + contentDescription = null + ) + } + }, + trailingIcon = { + Row { + if (!activeState.value) menuItems?.invoke(this) + } + }, + interactionSource = remember { MutableInteractionSource() }, + ) + }, + expanded = activeState.value, + onExpandedChange = onActiveChange, modifier = Modifier .align(Alignment.TopCenter) .alpha(searchBarAlpha), - enabled = (selectionState == null || !selectionState.value) && !isScrolling.value, - query = query, - onQueryChange = { - query = it - if (it != mediaViewModel.lastQuery.value && mediaViewModel.lastQuery.value.isNotEmpty()) - mediaViewModel.clearQuery() - }, - onSearch = { - if (it.isNotEmpty()) - historySet = - historySet.toMutableSet().apply { add("${System.currentTimeMillis()}/$it") } - mediaViewModel.queryMedia(it) - }, - active = activeState.value, - onActiveChange = { activeState.value = it }, - placeholder = { - Text(text = stringResource(id = R.string.searchbar_title)) - }, + colors = colors, tonalElevation = elevation, - leadingIcon = { - IconButton( - enabled = selectionState == null, - onClick = { - scope.launch { - activeState.value = !activeState.value - if (query.isNotEmpty()) query = "" - mediaViewModel.clearQuery() - } - }) { - val leadingIcon = if (activeState.value) - Icons.AutoMirrored.Outlined.ArrowBack else Icons.Outlined.Search - Icon( - imageVector = leadingIcon, - modifier = Modifier.fillMaxHeight(), - contentDescription = null - ) - } - }, - trailingIcon = { - Row { - if (!activeState.value) menuItems?.invoke(this) - } - }, - colors = SearchBarDefaults.colors( - dividerColor = Color.Transparent - ) - ) { - /** - * Recent searches - */ - val lastQueryIsEmpty = remember(mediaViewModel.lastQuery.value) { mediaViewModel.lastQuery.value.isEmpty() } - AnimatedVisibility( - visible = lastQueryIsEmpty, - enter = enterAnimation, - exit = exitAnimation - ) { - SearchHistory { - query = it - mediaViewModel.queryMedia(it) + content = { + /** + * Recent searches + */ + val lastQueryIsEmpty = + remember(mediaViewModel.lastQuery.value) { mediaViewModel.lastQuery.value.isEmpty() } + AnimatedVisibility( + visible = lastQueryIsEmpty, + enter = enterAnimation, + exit = exitAnimation + ) { + SearchHistory { + query = it + mediaViewModel.queryMedia(it) + } } - } - /** - * Searched content - */ - AnimatedVisibility( - visible = !lastQueryIsEmpty, - enter = enterAnimation, - exit = exitAnimation - ) { - val mediaIsEmpty = remember(state) { state.media.isEmpty() && !state.isLoading } - if (mediaIsEmpty) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 72.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Outlined.ImageSearch, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + /** + * Searched content + */ + AnimatedVisibility( + visible = !lastQueryIsEmpty, + enter = enterAnimation, + exit = exitAnimation + ) { + val mediaIsEmpty = remember(state) { state.media.isEmpty() && !state.isLoading } + if (mediaIsEmpty) { + Column( modifier = Modifier - .size(64.dp) + .fillMaxWidth() + .padding(top = 72.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Outlined.ImageSearch, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(64.dp) + ) + Text( + text = stringResource(R.string.no_media_found), + style = MaterialTheme.typography.titleMedium + ) + } + } else { + val pd = PaddingValues( + bottom = bottomPadding + 16.dp ) - Text( - text = stringResource(R.string.no_media_found), - style = MaterialTheme.typography.titleMedium + var canScroll by rememberSaveable { mutableStateOf(true) } + var lastCellIndex by rememberGridSize() + val pinchState = rememberPinchZoomGridState( + cellsList = cellsList, + initialCellsIndex = lastCellIndex ) - } - } else { - val pd = PaddingValues( - bottom = bottomPadding + 16.dp - ) - var canScroll by rememberSaveable { mutableStateOf(true) } - var lastCellIndex by rememberGridSize() - val pinchState = rememberPinchZoomGridState( - cellsList = cellsList, - initialCellsIndex = lastCellIndex - ) - LaunchedEffect(pinchState.currentCells) { - lastCellIndex = cellsList.indexOf(pinchState.currentCells) - } - LaunchedEffect(pinchState.isZooming) { - canScroll = !pinchState.isZooming - } - - PinchZoomGridLayout(state = pinchState) { - MediaGridView( - mediaState = state, - paddingValues = pd, - canScroll = canScroll, - isScrolling = remember { mutableStateOf(false) } - ) { - navigate(Screen.MediaViewScreen.route + "?mediaId=${it.id}") + LaunchedEffect(pinchState.currentCells) { + lastCellIndex = cellsList.indexOf(pinchState.currentCells) } - androidx.compose.animation.AnimatedVisibility( - visible = state.isLoading, - enter = enterAnimation, - exit = exitAnimation - ) { - LoadingMedia(paddingValues = pd) + LaunchedEffect(pinchState.isZooming) { + canScroll = !pinchState.isZooming } - } - } - } + PinchZoomGridLayout(state = pinchState) { + MediaGridView( + mediaState = state, + paddingValues = pd, + canScroll = canScroll, + isScrolling = remember { mutableStateOf(false) } + ) { + navigate(Screen.MediaViewScreen.route + "?mediaId=${it.id}") + } + androidx.compose.animation.AnimatedVisibility( + visible = state.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + LoadingMedia(paddingValues = pd) + } + } - } + } + } + }, + ) } BackHandler(activeState.value) { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt index d74d2cc98a..312bd508d7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/SettingsScreen.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.window.DialogProperties import com.dot.gallery.R import com.dot.gallery.core.Position import com.dot.gallery.core.Settings +import com.dot.gallery.core.Settings.Misc.rememberAutoHideNavBar +import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar import com.dot.gallery.core.Settings.Misc.rememberForcedLastScreen import com.dot.gallery.core.Settings.Misc.rememberLastScreen import com.dot.gallery.core.SettingsEntity @@ -309,14 +311,6 @@ fun rememberSettingsList( ) } - val blacklistPref = remember { - SettingsEntity.Preference( - title = context.getString(R.string.ignored_albums), - summary = context.getString(R.string.blacklist_summary), - screenPosition = Position.Top - ) { navigate(Screen.BlacklistScreen()) } - } - var groupByMonth by Settings.Misc.rememberTimelineGroupByMonth() val groupByMonthPref = remember(groupByMonth) { SettingsEntity.SwitchPreference( @@ -330,7 +324,7 @@ fun rememberSettingsList( context.restartApplication() } }, - screenPosition = Position.Middle + screenPosition = Position.Top ) } @@ -354,7 +348,7 @@ fun rememberSettingsList( summary = context.getString(R.string.old_navbar_summary), isChecked = showOldNavbar, onCheck = { showOldNavbar = it }, - screenPosition = Position.Middle + screenPosition = Position.Top ) } @@ -390,6 +384,30 @@ fun rememberSettingsList( ) } + var autoHideSearchSetting by rememberAutoHideSearchBar() + + val autoHideSearch = remember(autoHideSearchSetting) { + SettingsEntity.SwitchPreference( + title = context.getString(R.string.auto_hide_searchbar), + summary = context.getString(R.string.auto_hide_searchbar_summary), + isChecked = autoHideSearchSetting, + onCheck = { autoHideSearchSetting = it }, + screenPosition = Position.Middle + ) + } + + var autoHideNavigationSetting by rememberAutoHideNavBar() + + val autoHideNavigation = remember(autoHideNavigationSetting) { + SettingsEntity.SwitchPreference( + title = context.getString(R.string.auto_hide_navigationbar), + summary = context.getString(R.string.auto_hide_navigationbar_summary), + isChecked = autoHideNavigationSetting, + onCheck = { autoHideNavigationSetting = it }, + screenPosition = Position.Bottom + ) + } + return remember( arrayOf( forceTheme, @@ -411,25 +429,32 @@ fun rememberSettingsList( add(amoledModePref) /** ********************* **/ /** ********************* **/ + add(SettingsEntity.Header(title = context.getString(R.string.settings_general))) + /** General Section Start **/ + /** General Section Start **/ + add(trashCanEnabledPref) + add(secureModePref) + add(allowVibrationsPref) + /** ********************* **/ + /** ********************* **/ add(SettingsEntity.Header(title = context.getString(R.string.customization))) /** Customization Section Start **/ /** Customization Section Start **/ - add(blacklistPref) add(groupByMonthPref) add(allowBlurPref) - add(showOldNavbarPref) add(hideTimelineOnAlbumPref) add(forcedLastScreenPref) /** ********************* **/ /** ********************* **/ - add(SettingsEntity.Header(title = context.getString(R.string.settings_general))) - /** General Section Start **/ - /** General Section Start **/ - add(trashCanEnabledPref) - add(secureModePref) - add(allowVibrationsPref) - /** General Section End **/ - /** General Section End **/ + /** Navigation Section Start **/ + /** Navigation Section Start **/ + add(SettingsEntity.Header(title = context.getString(R.string.navigation))) + add(showOldNavbarPref) + add(autoHideSearch) + add(autoHideNavigation) + /** ********************* **/ + /** ********************* **/ + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt index b1c5059d1e..cd38b05341 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/settings/components/SettingsItems.kt @@ -12,9 +12,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -42,6 +45,7 @@ import com.dot.gallery.core.SettingsEntity.Preference import com.dot.gallery.core.SettingsEntity.SeekPreference import com.dot.gallery.core.SettingsEntity.SwitchPreference import com.dot.gallery.core.SettingsType +import com.dot.gallery.ui.core.icons.RegularExpression import com.dot.gallery.ui.theme.GalleryTheme import kotlin.math.roundToLong @@ -56,6 +60,7 @@ fun SettingsItem( require(item.icon != null) { "Icon at this stage cannot be null" } Icon( imageVector = item.icon!!, + modifier = Modifier.size(24.dp), contentDescription = null ) } @@ -219,6 +224,7 @@ fun SettingsItemGroupPreview() = ) SettingsItem( item = Preference( + icon = Icons.Outlined.Settings, title = "Preview Top Title", summary = "Preview Summary", screenPosition = Position.Top @@ -226,6 +232,7 @@ fun SettingsItemGroupPreview() = ) SettingsItem( item = SeekPreference( + icon = com.dot.gallery.ui.core.Icons.RegularExpression, title = "Preview Middle Title", summary = "Preview Summary\nSecond Line", currentValue = 330f, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt index c690b7ad48..d9ff7e716e 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/setup/SetupScreen.kt @@ -6,22 +6,11 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,21 +19,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.ParagraphStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Constants import com.dot.gallery.core.Settings.Misc.rememberIsMediaManager +import com.dot.gallery.core.presentation.components.SetupWizard import com.dot.gallery.feature_node.presentation.common.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionLayout import com.dot.gallery.feature_node.presentation.util.RepeatOnResume @@ -77,149 +62,99 @@ fun SetupScreen( context.getString(R.string.some_permissions_are_not_granted), Toast.LENGTH_LONG) .show() } - Scaffold( - modifier = Modifier.fillMaxSize(), + + SetupWizard( + painter = painterResource(R.drawable.ic_gallery_thumbnail), + title = stringResource(id = R.string.welcome), + subtitle = appName, bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surface - ) - .navigationBarsPadding() - .padding(horizontal = 24.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween + OutlinedButton( + onClick = { (context as Activity).finish() } ) { - OutlinedButton( - onClick = { (context as Activity).finish() } - ) { - Text(text = stringResource(id = R.string.action_cancel)) - } + Text(text = stringResource(id = R.string.action_cancel)) + } - Button( - onClick = { - scope.launch { - mediaPermissions.launchMultiplePermissionRequest() - } + Button( + onClick = { + scope.launch { + mediaPermissions.launchMultiplePermissionRequest() } - ) { - Text(text = stringResource(R.string.get_started)) } + ) { + Text(text = stringResource(R.string.get_started)) } - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .padding(top = 24.dp) - .verticalScroll(state = rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = R.drawable.ic_gallery_thumbnail), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) + }, + content = { Text( - text = buildAnnotatedString { - val headLineMedium = MaterialTheme.typography.headlineMedium.toSpanStyle() - val bodyLarge = MaterialTheme.typography.bodyLarge.toSpanStyle() - val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant - withStyle(style = ParagraphStyle(textAlign = TextAlign.Center)) { - withStyle( - style = headLineMedium - ) { - append(context.getString(R.string.welcome)) - } - appendLine() - withStyle( - style = bodyLarge - .copy(color = onSurfaceVariant) - ) { - append(appName) - } - } + modifier = Modifier + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + text = stringResource(R.string.required) + ) + OptionLayout( + modifier = Modifier.fillMaxWidth(), + optionList = context.requiredPermissionsList.map { (title, summary) -> + OptionItem( + text = title, + summary = summary, + enabled = true, + onClick = { } + ) } ) - Column( - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - ) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + var useMediaManager by rememberIsMediaManager() + var isStorageManager by remember { mutableStateOf(Environment.isExternalStorageManager()) } + RepeatOnResume { + isStorageManager = Environment.isExternalStorageManager() + useMediaManager = MediaStore.canManageMedia(context) + } + Text( - modifier = Modifier - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - text = stringResource(R.string.required) + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.optional) ) - OptionLayout( - modifier = Modifier.fillMaxWidth(), - optionList = context.requiredPermissionsList.map { (title, summary) -> + val grantedString = stringResource(R.string.granted) + val secondaryContainer = MaterialTheme.colorScheme.secondaryContainer + val onSecondaryContainer = MaterialTheme.colorScheme.onSecondaryContainer + val optionsList = remember(useMediaManager, isStorageManager) { + listOf( OptionItem( - text = title, - summary = summary, - enabled = true, - onClick = { } - ) - } - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - var useMediaManager by rememberIsMediaManager() - var isStorageManager by remember { mutableStateOf(Environment.isExternalStorageManager()) } - RepeatOnResume { - isStorageManager = Environment.isExternalStorageManager() - useMediaManager = MediaStore.canManageMedia(context) - } - - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(R.string.optional) - ) - val grantedString = stringResource(R.string.granted) - val secondaryContainer = MaterialTheme.colorScheme.secondaryContainer - val onSecondaryContainer = MaterialTheme.colorScheme.onSecondaryContainer - val optionsList = remember(useMediaManager, isStorageManager) { - listOf( - OptionItem( - text = context.getString(R.string.permission_manage_media_title), - summary = if (!useMediaManager) context.getString(R.string.permission_manage_media_summary) else grantedString, - enabled = !useMediaManager, - onClick = { - scope.launch { - context.launchManageMedia() - } - }, - containerColor = secondaryContainer, - contentColor = onSecondaryContainer - ), - OptionItem( - text = context.getString(R.string.permission_manage_files_title), - summary = if (!isStorageManager && isManageFilesAllowed) context.getString(R.string.permission_manage_files_summary) else grantedString, - enabled = !isStorageManager && isManageFilesAllowed, - onClick = { - scope.launch { - context.launchManageFiles() - } - }, + text = context.getString(R.string.permission_manage_media_title), + summary = if (!useMediaManager) context.getString(R.string.permission_manage_media_summary) else grantedString, + enabled = !useMediaManager, + onClick = { + scope.launch { + context.launchManageMedia() + } + }, + containerColor = secondaryContainer, + contentColor = onSecondaryContainer + ), + OptionItem( + text = context.getString(R.string.permission_manage_files_title), + summary = if (!isStorageManager && isManageFilesAllowed) context.getString(R.string.permission_manage_files_summary) else grantedString, + enabled = !isStorageManager && isManageFilesAllowed, + onClick = { + scope.launch { + context.launchManageFiles() + } + }, - containerColor = secondaryContainer, - contentColor = onSecondaryContainer - ) + containerColor = secondaryContainer, + contentColor = onSecondaryContainer ) - } - - OptionLayout( - modifier = Modifier.fillMaxWidth(), - optionList = optionsList ) } + + OptionLayout( + modifier = Modifier.fillMaxWidth(), + optionList = optionsList + ) } } - } + ) } private val Context.requiredPermissionsList: Array> get() { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt index 201dd72765..d7c2838913 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt @@ -16,20 +16,15 @@ import androidx.compose.runtime.getValue import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.setSingletonImageLoaderFactory import com.dot.gallery.core.AlbumState import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreen -import com.dot.gallery.feature_node.presentation.util.newImageLoader import com.dot.gallery.feature_node.presentation.util.toggleOrientation import com.dot.gallery.ui.theme.GalleryTheme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.MutableStateFlow @AndroidEntryPoint class StandaloneActivity : ComponentActivity() { - @OptIn(ExperimentalCoilApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -46,7 +41,6 @@ class StandaloneActivity : ComponentActivity() { } setShowWhenLocked(isSecure) setContent { - setSingletonImageLoaderFactory(::newImageLoader) GalleryTheme(darkTheme = true) { Scaffold { paddingValues -> val viewModel = hiltViewModel().apply { @@ -54,14 +48,15 @@ class StandaloneActivity : ComponentActivity() { dataList = uriList.toList() } val vaults by viewModel.vaults.collectAsStateWithLifecycle() + val mediaState by viewModel.mediaState.collectAsStateWithLifecycle() MediaViewScreen( navigateUp = { finish() }, toggleRotate = ::toggleOrientation, paddingValues = paddingValues, isStandalone = true, mediaId = viewModel.mediaId, - mediaState = viewModel.mediaState, - albumsState = MutableStateFlow(AlbumState()), + mediaState = mediaState, + albumsState = AlbumState(), handler = viewModel.handler, addMedia = viewModel::addMedia, vaults = vaults diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt index 5f83e14301..56d4c1c4e6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/support/SupportSheet.kt @@ -178,7 +178,7 @@ fun SupportSheet( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, tonalElevation = 0.dp, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt index b38b08e4be..96a5bbe859 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/TimelineScreen.kt @@ -74,7 +74,7 @@ fun TimelineScreen( navigateUp = navigateUp ) }, - emptyContent = { EmptyMedia(Modifier.fillMaxSize()) }, + emptyContent = { padding -> EmptyMedia(Modifier.fillMaxSize(), padding) }, navigate = navigate, navigateUp = navigateUp, toggleNavbar = toggleNavbar, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt index d22e29fb03..70d0b9dafa 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/TrashedScreen.kt @@ -19,12 +19,12 @@ import com.dot.gallery.R import com.dot.gallery.core.AlbumState import com.dot.gallery.core.Constants.Target.TARGET_TRASH import com.dot.gallery.core.MediaState +import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.presentation.common.MediaScreen import com.dot.gallery.feature_node.presentation.common.MediaViewModel import com.dot.gallery.feature_node.presentation.trashed.components.AutoDeleteFooter -import com.dot.gallery.feature_node.presentation.trashed.components.EmptyTrash import com.dot.gallery.feature_node.presentation.trashed.components.TrashedNavActions import kotlinx.coroutines.flow.StateFlow @@ -59,7 +59,7 @@ fun TrashedGridScreen( _: ActivityResultLauncher -> TrashedNavActions(handler, mediaState, selectedMedia, selectionState) }, - emptyContent = { EmptyTrash(Modifier.fillMaxSize()) }, + emptyContent = { padding -> EmptyMedia(Modifier.fillMaxSize(), padding) }, aboveGridContent = { AutoDeleteFooter() }, navigate = navigate, navigateUp = navigateUp, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt index d7c19d954b..16191033dd 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashDialog.kt @@ -57,10 +57,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.CachePolicy -import coil3.request.ImageRequest import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation @@ -74,6 +70,7 @@ import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion. import com.dot.gallery.feature_node.presentation.util.canBeTrashed import com.dot.gallery.feature_node.presentation.util.mediaPair import com.dot.gallery.ui.theme.Shapes +import com.github.panpf.sketch.AsyncImage import kotlinx.coroutines.launch @OptIn( @@ -108,7 +105,7 @@ fun TrashDialog( } }, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { val tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer val tertiaryOnContainer = MaterialTheme.colorScheme.onTertiaryContainer @@ -263,7 +260,7 @@ fun TrashDialog( val feedbackManager = rememberFeedbackManager() Box( modifier = Modifier - .animateItemPlacement() + .animateItem() .size(width = 80.dp, height = 120.dp) .clip(shape) .border( @@ -293,11 +290,7 @@ fun TrashDialog( ) { AsyncImage( modifier = Modifier.fillMaxSize(), - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(it.uri) - .diskCachePolicy(CachePolicy.ENABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build(), + uri = it.uri.toString(), contentDescription = it.label, contentScale = ContentScale.Crop ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedViewBottomBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedViewBottomBar.kt index 43a31daaf0..db94de5f5a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedViewBottomBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedViewBottomBar.kt @@ -17,6 +17,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.RestoreFromTrash import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,6 +35,8 @@ import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.ui.theme.BlackScrim import kotlinx.coroutines.launch +@Stable +@NonRestartableComposable @Composable fun BoxScope.TrashedViewBottomBar( handler: MediaHandleUseCase, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt index eca47596f1..d83c791d3c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ContextExt.kt @@ -36,7 +36,6 @@ import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Settings.Misc.allowVibrations import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.presentation.edit.EditActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest @@ -169,9 +168,9 @@ fun Context.launchEditImageIntent(packageName: String, uri: Uri) { } fun Context.launchEditIntent(media: Media) { - if (media.isImage) { - EditActivity.launchEditor(this@launchEditIntent, media.uri) - } else { +// if (media.isImage) { +// EditActivity.launchEditor(this@launchEditIntent, media.uri) +// } else { val intent = Intent(Intent.ACTION_EDIT).apply { addCategory(Intent.CATEGORY_DEFAULT) setDataAndType(media.uri, media.mimeType) @@ -179,7 +178,7 @@ fun Context.launchEditIntent(media: Media) { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(intent, getString(R.string.edit))) - } +// } } suspend fun Context.launchUseAsIntent(media: Media) = diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt index 33b5d18b4b..6c2c564261 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/FileUtils.kt @@ -21,8 +21,21 @@ import java.io.File import java.io.FileOutputStream import java.math.RoundingMode import java.text.DecimalFormat +import java.util.Locale import java.util.UUID +import kotlin.math.log10 import kotlin.math.min +import kotlin.math.pow + +fun formatSize(size: Long): String { + if (size <= 0) return "0 B" + + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt() + + val formattedSize = size / 1024.0.pow(digitGroups.toDouble()) + return String.format(Locale.getDefault(), "%.2f %s", formattedSize, units[digitGroups]) +} fun File.formattedFileSize(context: Context): String { var fileSize = this.length().toDouble() / 1024.0 diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt deleted file mode 100644 index e4509daa7e..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/JxlDecoder.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2023 Radzivon Bartoshyk - * jxl-coder [https://github.com/awxkee/jxl-coder] - * - * Created by Radzivon Bartoshyk on 18/9/2023 - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - -package com.dot.gallery.feature_node.presentation.util - -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.asImage -import coil3.decode.DecodeResult -import coil3.decode.Decoder -import coil3.fetch.SourceFetchResult -import coil3.request.Options -import coil3.size.Scale -import coil3.size.Size -import coil3.size.pxOrElse -import com.awxkee.jxlcoder.JxlCoder -import com.awxkee.jxlcoder.JxlResizeFilter -import com.awxkee.jxlcoder.PreferredColorConfig -import com.awxkee.jxlcoder.ScaleMode -import kotlinx.coroutines.runInterruptible -import okio.BufferedSource -import okio.ByteString.Companion.toByteString - -class JxlDecoder( - private val source: SourceFetchResult, - private val options: Options, - private val imageLoader: ImageLoader -) : Decoder { - - @OptIn(ExperimentalCoilApi::class) - override suspend fun decode(): DecodeResult = runInterruptible { - // ColorSpace is preferred to be ignored due to lib is trying to handle all color profile by itself - val sourceData = source.source.source().readByteArray() - if (options.size == Size.ORIGINAL) { - val originalImage = - JxlCoder.decode( - sourceData, - preferredColorConfig = PreferredColorConfig.DEFAULT - ) - return@runInterruptible DecodeResult( - image = originalImage.asImage(), - isSampled = false - ) - } - - val dstWidth = options.size.width.pxOrElse { 0 } - val dstHeight = options.size.height.pxOrElse { 0 } - val scaleMode = when (options.scale) { - Scale.FILL -> ScaleMode.FILL - Scale.FIT -> ScaleMode.FIT - } - - val originalImage = - JxlCoder.decodeSampled( - sourceData, - dstWidth, - dstHeight, - preferredColorConfig = PreferredColorConfig.DEFAULT, - scaleMode, - JxlResizeFilter.BILINEAR, - ) - DecodeResult( - image = originalImage.asImage(), - isSampled = true - ) - } - - class Factory : Decoder.Factory { - - private val MAGIC_1 = byteArrayOf(0xFF.toByte(), 0x0A).toByteString() - private val MAGIC_2 = byteArrayOf( - 0x0.toByte(), - 0x0.toByte(), - 0x0.toByte(), - 0x0C.toByte(), - 0x4A, - 0x58, - 0x4C, - 0x20, - 0x0D, - 0x0A, - 0x87.toByte(), - 0x0A - ).toByteString() - - private fun isJXL(source: BufferedSource): Boolean { - return source.rangeEquals(0, MAGIC_1) || source.rangeEquals( - 0, - MAGIC_2 - ) - } - - override fun create( - result: SourceFetchResult, - options: Options, - imageLoader: ImageLoader - ) = if (isJXL(result.source.source())) { - JxlDecoder(result, options, imageLoader) - } else null - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt index a418babc61..5c34024c08 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt @@ -25,7 +25,8 @@ sealed class Screen(val route: String) { data object FavoriteScreen : Screen("favorite_screen") data object SettingsScreen : Screen("settings_screen") - data object BlacklistScreen : Screen("blacklist_screen") + data object IgnoredScreen : Screen("ignored_screen") + data object IgnoredSetupScreen : Screen("ignored_setup_screen") data object SetupScreen: Screen("setup_screen") diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt deleted file mode 100644 index 76ddd12120..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/newImageLoader.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.dot.gallery.feature_node.presentation.util - -import android.app.ActivityManager -import android.graphics.Bitmap -import androidx.core.content.getSystemService -import coil3.ImageLoader -import coil3.PlatformContext -import coil3.disk.DiskCache -import coil3.disk.directory -import coil3.gif.AnimatedImageDecoder -import coil3.memory.MemoryCache -import coil3.request.allowRgb565 -import coil3.request.bitmapConfig -import coil3.request.crossfade -import coil3.svg.SvgDecoder -import com.dot.gallery.core.coil.ThumbnailDecoder -import com.github.awxkee.avifcoil.decoder.HeifDecoder3 - -fun newImageLoader( - context: PlatformContext -): ImageLoader { - val activityManager: ActivityManager = context.getSystemService()!! - val memoryPercent = if (activityManager.isLowRamDevice) 0.25 else 0.75 - return ImageLoader.Builder(context) - .components { - add(HeifDecoder3.Factory(context)) - // SVGs - add(SvgDecoder.Factory(false)) - add(JxlDecoder.Factory()) - // GIFs - add(AnimatedImageDecoder.Factory()) - // Thumbnails - add(ThumbnailDecoder.Factory()) - } - .memoryCache { - MemoryCache.Builder() - .maxSizePercent(context, percent = memoryPercent) - .build() - } - .diskCache { - DiskCache.Builder() - .directory(context.cacheDir.resolve("image_cache/coil").absoluteFile) - .maxSizePercent(1.0) - .build() - } - // Show a short crossfade when loading images asynchronously. - .crossfade(100) - .allowRgb565(true) - .bitmapConfig(Bitmap.Config.HARDWARE) - .build() -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt index fac423bf41..cb1e84f9cc 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt @@ -49,7 +49,10 @@ fun VaultScreen( onSuccess = { isAuthenticated = true }, - onFailed = { isAuthenticated = false }, + onFailed = { + isAuthenticated = false + navigateUp() + }, biometricPromptInfo = PromptInfo.Builder() .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) .setTitle(stringResource(R.string.biometric_authentication)) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt index 1f0440e799..93c5a25273 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt @@ -49,7 +49,7 @@ fun DeleteVaultSheet( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, tonalElevation = 0.dp, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt index 3eb6674a60..f6c69c80ec 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt @@ -180,7 +180,7 @@ fun PinchZoomGridScope.EncryptedMediaGridView( } else { EncryptedMediaImage( modifier = Modifier - .animateItemPlacement() + .animateItem() .pinchItem(key = it.key), media = (item as EncryptedMediaItem.MediaViewItem).media, selectionState = selectionState, @@ -208,7 +208,7 @@ fun PinchZoomGridScope.EncryptedMediaGridView( ) { index, media -> EncryptedMediaImage( modifier = Modifier - .animateItemPlacement() + .animateItem() .pinchItem(key = media.toString()), media = media, selectionState = selectionState, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt index 50ca3cce09..dc259d2122 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt @@ -10,7 +10,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable @@ -34,19 +33,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import coil3.compose.LocalPlatformContext -import coil3.compose.rememberAsyncImagePainter -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Scale import com.dot.gallery.core.Constants.Animation import com.dot.gallery.core.presentation.components.CheckBox import com.dot.gallery.feature_node.domain.model.EncryptedMedia -import com.dot.gallery.feature_node.domain.model.MediaEqualityDelegate import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.VideoDurationHeader +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.cache.CachePolicy +import com.github.panpf.sketch.fetch.newBase64Uri +import com.github.panpf.sketch.request.ComposableImageRequest +import com.github.panpf.sketch.resize.Scale @OptIn(ExperimentalFoundationApi::class) @Composable @@ -82,17 +79,6 @@ fun EncryptedMediaImage( targetValue = if (isSelected) primaryContainerColor else Color.Transparent, label = "strokeColor" ) - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(media.bytes) - .memoryCachePolicy(CachePolicy.ENABLED) - .placeholderMemoryCacheKey(media.toString()) - .scale(Scale.FIT) - .build(), - modelEqualityDelegate = MediaEqualityDelegate(), - contentScale = ContentScale.FillBounds, - filterQuality = FilterQuality.None - ) Box( modifier = modifier .combinedClickable( @@ -128,10 +114,14 @@ fun EncryptedMediaImage( color = strokeColor ) ) { - Image( + AsyncImage( modifier = Modifier .fillMaxSize(), - painter = painter, + request = ComposableImageRequest(newBase64Uri(mimeType = media.mimeType, imageData = media.bytes)) { + memoryCachePolicy(CachePolicy.ENABLED) + scale(Scale.CENTER_CROP) + crossfade() + }, contentDescription = media.label, contentScale = ContentScale.Crop, ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt index f2b95b8728..0d203f66a4 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt @@ -48,10 +48,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.CachePolicy -import coil3.request.ImageRequest import com.dot.gallery.R import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation @@ -64,6 +60,8 @@ import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogA import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager import com.dot.gallery.ui.theme.Shapes +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.fetch.newBase64Uri import kotlinx.coroutines.launch @OptIn( @@ -98,7 +96,7 @@ fun EncryptedTrashDialog( } }, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { val tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer val tertiaryOnContainer = MaterialTheme.colorScheme.onTertiaryContainer @@ -216,7 +214,7 @@ fun EncryptedTrashDialog( val feedbackManager = rememberFeedbackManager() Box( modifier = Modifier - .animateItemPlacement() + .animateItem() .size(width = 80.dp, height = 120.dp) .clip(shape) .border( @@ -246,11 +244,7 @@ fun EncryptedTrashDialog( ) { AsyncImage( modifier = Modifier.fillMaxSize(), - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(it.bytes) - .diskCachePolicy(CachePolicy.ENABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build(), + uri = newBase64Uri(it.mimeType, it.bytes), contentDescription = it.label, contentScale = ContentScale.Crop ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt index 3e1f30f155..b771bed771 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt @@ -49,7 +49,7 @@ fun NewVaultSheet( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, tonalElevation = 0.dp, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt index 6d14f8229a..38d1e1802f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt @@ -62,7 +62,7 @@ fun SelectVaultSheet( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, tonalElevation = 0.dp, dragHandle = { DragHandle() }, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt index eb3a6bf984..c6d0927f37 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt @@ -53,6 +53,7 @@ import com.dot.gallery.core.Constants.HEADER_DATE_FORMAT import com.dot.gallery.core.EncryptedMediaState import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayerController import com.dot.gallery.feature_node.presentation.util.getDate import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController @@ -60,7 +61,6 @@ import com.dot.gallery.feature_node.presentation.util.toggleSystemBars import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.EncryptedMediaViewAppBar import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.EncryptedMediaViewBottomBar import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.media.MediaPreviewComponent -import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.VideoPlayerController import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -149,7 +149,7 @@ fun EncryptedMediaViewScreen( state = pagerState, flingBehavior = PagerDefaults.flingBehavior( state = pagerState, - lowVelocityAnimationSpec = tween( + snapAnimationSpec = tween( easing = FastOutLinearInEasing, durationMillis = DEFAULT_LOW_VELOCITY_SWIPE_DURATION ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt index 87f06a1bee..33d7a8acfa 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt @@ -139,7 +139,7 @@ fun EncryptedMediaInfoBottomSheet( dragHandle = { DragHandle() }, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetState = state.sheetState, - windowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = { WindowInsets(0, 0, 0, 0) } ) { BackHandler { scope.launch { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt index 314b3e4e52..8d111e3790 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt @@ -8,6 +8,7 @@ package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.compo import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.media3.exoplayer.ExoPlayer @@ -20,7 +21,7 @@ fun MediaPreviewComponent( uiEnabled: Boolean, playWhenReady: Boolean, onItemClick: () -> Unit, - videoController: @Composable (ExoPlayer, MutableState, MutableState, Long, Int, Float) -> Unit, + videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, ) { Box( modifier = Modifier diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt index 534585d5c4..0fa76a8369 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt @@ -19,55 +19,29 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import coil3.compose.LocalPlatformContext -import coil3.compose.rememberAsyncImagePainter -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Scale import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION import com.dot.gallery.core.Settings import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus import com.dot.gallery.feature_node.domain.model.EncryptedMedia -import me.saket.telephoto.zoomable.ZoomSpec -import me.saket.telephoto.zoomable.ZoomableImage -import me.saket.telephoto.zoomable.ZoomableImageSource -import me.saket.telephoto.zoomable.coil3.coil3 -import me.saket.telephoto.zoomable.rememberZoomableImageState -import me.saket.telephoto.zoomable.rememberZoomableState -import net.engawapg.lib.zoomable.rememberZoomState +import com.github.panpf.sketch.fetch.newBase64Uri +import com.github.panpf.sketch.request.ComposableImageRequest +import com.github.panpf.zoomimage.SketchZoomAsyncImage @Composable fun ZoomablePagerImage( modifier: Modifier = Modifier, media: EncryptedMedia, uiEnabled: Boolean, - maxScale: Float = 10f, onItemClick: () -> Unit ) { - val zoomState = rememberZoomState( - maxScale = maxScale, - ) - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(media.bytes) - .memoryCachePolicy(CachePolicy.ENABLED) - .placeholderMemoryCacheKey(media.toString()) - .scale(Scale.FILL) - .build(), + val painter = com.github.panpf.sketch.rememberAsyncImagePainter( + request = ComposableImageRequest(newBase64Uri(mimeType = media.mimeType, imageData = media.bytes)) { + memoryCachePolicy(com.github.panpf.sketch.cache.CachePolicy.ENABLED) + crossfade() + }, contentScale = ContentScale.Fit, filterQuality = FilterQuality.None, - onSuccess = { - zoomState.setContentSize(it.painter.intrinsicSize) - } - ) - val zoomableState = rememberZoomableState( - zoomSpec = ZoomSpec( - maxZoomFactor = maxScale - ) - ) - val state = rememberZoomableImageState( - zoomableState = zoomableState ) Box(modifier = Modifier.fillMaxSize()) { @@ -92,16 +66,10 @@ fun ZoomablePagerImage( } } - ZoomableImage( + SketchZoomAsyncImage( modifier = modifier.fillMaxSize(), - onClick = { onItemClick() }, - state = state, - image = ZoomableImageSource.coil3( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(media.bytes) - .placeholderMemoryCacheKey(media.toString()) - .build() - ), + onTap = { onItemClick() }, + uri = newBase64Uri(mimeType = media.mimeType, imageData = media.bytes), contentScale = ContentScale.Fit, contentDescription = media.label ) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt index e856af812c..0241d43871 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableLongState import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -107,7 +108,7 @@ object UriByteDataHelper { fun VideoPlayer( media: EncryptedMedia, playWhenReady: Boolean, - videoController: @Composable (ExoPlayer, MutableState, MutableState, Long, Int, Float) -> Unit, + videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, onItemClick: () -> Unit ) { var totalDuration by rememberSaveable { mutableLongStateOf(0L) } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt deleted file mode 100644 index c5620ecbfe..0000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt +++ /dev/null @@ -1,265 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.VolumeMute -import androidx.compose.material.icons.automirrored.outlined.VolumeUp -import androidx.compose.material.icons.filled.PauseCircleFilled -import androidx.compose.material.icons.filled.PlayCircleFilled -import androidx.compose.material.icons.outlined.ScreenRotation -import androidx.compose.material.icons.outlined.Speed -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.media3.exoplayer.ExoPlayer -import com.dot.gallery.R -import com.dot.gallery.feature_node.domain.model.PlaybackSpeed -import com.dot.gallery.feature_node.presentation.util.formatMinSec -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@Composable -fun VideoPlayerController( - paddingValues: PaddingValues, - player: ExoPlayer, - isPlaying: MutableState, - currentTime: MutableState, - totalTime: Long, - buffer: Int, - toggleRotate: () -> Unit, - frameRate: Float -) { - val scope = rememberCoroutineScope() - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)) - ) { - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(horizontal = 16.dp) - .padding(bottom = paddingValues.calculateBottomPadding() + 72.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.End - ) { - var isMuted by rememberSaveable(isPlaying) { mutableStateOf(player.volume == 0f) } - var currentVolume by rememberSaveable(player) { mutableFloatStateOf(player.volume) } - LaunchedEffect(LocalConfiguration.current, player.currentMediaItem) { - player.volume = if (isMuted) 0f else currentVolume - } - var auto by rememberSaveable { mutableStateOf(false) } - var showMenu by rememberSaveable { mutableStateOf(false) } - var playbackSpeed by rememberSaveable { mutableFloatStateOf(1f) } - val ctx = LocalContext.current - val playbackSpeeds = remember { - listOf( - PlaybackSpeed(1f / (frameRate / 30f), ctx.getString(R.string.auto), true), - PlaybackSpeed(0.125f, "0.125x"), - PlaybackSpeed(0.25f, "0.25x"), - PlaybackSpeed(0.5f, "0.5x"), - PlaybackSpeed(1f, "1x"), - PlaybackSpeed(2f, "2x") - ) - } - LaunchedEffect(playbackSpeed) { - player.setPlaybackSpeed(playbackSpeed) - showMenu = false - } - - Box(contentAlignment = Alignment.TopEnd) { - DropdownMenu( - expanded = showMenu, - onDismissRequest = { - showMenu = false - } - ) { - playbackSpeeds.forEach { speed -> - DropdownMenuItem( - modifier = Modifier.padding(end = 16.dp), - onClick = { - playbackSpeed = speed.speed - auto = speed.isAuto - }, - leadingIcon = { - RadioButton( - selected = playbackSpeed == speed.speed && !speed.isAuto, - onClick = { - playbackSpeed = speed.speed - auto = speed.isAuto - } - ) - }, - text = { Text(text = speed.label) } - ) - } - } - IconButton( - onClick = { - showMenu = !showMenu - } - ) { - Icon( - imageVector = Icons.Outlined.Speed, - tint = Color.White, - contentDescription = stringResource(R.string.change_playback_speed_cd) - ) - } - } - IconButton( - onClick = { - if (isMuted) { - player.volume = currentVolume - isMuted = false - } else { - currentVolume = player.volume - player.volume = 0f - isMuted = true - } - } - ) { - Icon( - imageVector = if (isMuted) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Outlined.VolumeUp, - tint = Color.White, - contentDescription = stringResource( - R.string.toggle_audio_cd - ) - ) - } - IconButton( - onClick = { toggleRotate() } - ) { - Icon( - imageVector = Icons.Outlined.ScreenRotation, - tint = Color.White, - contentDescription = stringResource( - R.string.rotate_screen_cd - ) - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly - ) { - Text( - modifier = Modifier.width(52.dp), - text = currentTime.value.formatMinSec(), - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - textAlign = TextAlign.Center - ) - Box(Modifier.weight(1f)) { - Slider( - modifier = Modifier.fillMaxWidth(), - value = buffer.toFloat(), - enabled = false, - onValueChange = {}, - valueRange = 0f..100f, - colors = - SliderDefaults.colors( - disabledThumbColor = Color.Transparent, - disabledInactiveTrackColor = Color.DarkGray.copy(alpha = 0.4f), - disabledActiveTrackColor = Color.Gray - ) - ) - Slider( - modifier = Modifier.fillMaxWidth(), - value = currentTime.value.toFloat(), - onValueChange = { - scope.launch { - currentTime.value = it.toLong() - player.seekTo(it.toLong()) - delay(50) - player.play() - } - }, - valueRange = 0f..totalTime.toFloat(), - colors = - SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color.White, - activeTickColor = Color.White, - inactiveTrackColor = Color.Transparent - ) - ) - } - Text( - modifier = Modifier.width(52.dp), - text = totalTime.formatMinSec(), - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - textAlign = TextAlign.Center - ) - } - } - - IconButton( - onClick = { isPlaying.value = !isPlaying.value }, - modifier = Modifier - .align(Alignment.Center) - .size(64.dp) - ) { - if (isPlaying.value) { - Image( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Filled.PauseCircleFilled, - contentDescription = stringResource(R.string.pause_video), - colorFilter = ColorFilter.tint(Color.White) - ) - } else { - Image( - modifier = Modifier.fillMaxSize(), - imageVector = Icons.Filled.PlayCircleFilled, - contentDescription = stringResource(R.string.play_video), - colorFilter = ColorFilter.tint(Color.White) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt index 70a688d8f4..47ecd672a9 100644 --- a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt +++ b/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt @@ -9,8 +9,6 @@ import android.app.Application import android.content.ContentResolver import android.content.Context import androidx.room.Room -import coil.ImageLoader -import coil.request.ImageRequest import com.dot.gallery.feature_node.data.data_source.InternalDatabase import com.dot.gallery.feature_node.data.data_source.KeychainHolder import com.dot.gallery.feature_node.data.repository.MediaRepositoryImpl @@ -68,12 +66,4 @@ object AppModule { return MediaRepositoryImpl(context, database, keychainHolder) } - @Provides - @Singleton - fun getImageLoader(@ApplicationContext context: Context): ImageLoader = ImageLoader(context) - - @Provides - fun getImageRequest(@ApplicationContext context: Context): ImageRequest.Builder = - ImageRequest.Builder(context) - } diff --git a/app/src/main/kotlin/com/dot/gallery/ui/core/icons/RegularExpression.kt b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/RegularExpression.kt new file mode 100644 index 0000000000..b065ad25ae --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/RegularExpression.kt @@ -0,0 +1,87 @@ +package com.dot.gallery.ui.core.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.dot.gallery.ui.core.Icons + +public val Icons.RegularExpression: ImageVector + get() { + if (_regularExpression != null) { + return _regularExpression!! + } + _regularExpression = Builder(name = "RegularExpression", defaultWidth = 48.0.dp, + defaultHeight = 48.0.dp, viewportWidth = 48.0f, viewportHeight = 48.0f).apply { + group { + path(fill = SolidColor(Color(0xFFD0BCFF)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(9.85f, 38.05f) + curveTo(7.9833f, 36.15f, 6.5417f, 33.9833f, 5.525f, 31.55f) + curveTo(4.5083f, 29.1167f, 4.0f, 26.5667f, 4.0f, 23.9f) + curveTo(4.0f, 21.2333f, 4.5f, 18.6833f, 5.5f, 16.25f) + curveTo(6.5f, 13.8167f, 7.95f, 11.65f, 9.85f, 9.75f) + lineTo(12.7f, 12.6f) + curveTo(11.1667f, 14.1f, 10.0f, 15.825f, 9.2f, 17.775f) + curveTo(8.4f, 19.725f, 8.0f, 21.7667f, 8.0f, 23.9f) + curveTo(8.0f, 26.0333f, 8.4083f, 28.075f, 9.225f, 30.025f) + curveTo(10.0417f, 31.975f, 11.2f, 33.7f, 12.7f, 35.2f) + lineTo(9.85f, 38.05f) + close() + moveTo(19.0f, 36.0f) + curveTo(18.1667f, 36.0f, 17.4583f, 35.7083f, 16.875f, 35.125f) + curveTo(16.2917f, 34.5417f, 16.0f, 33.8333f, 16.0f, 33.0f) + curveTo(16.0f, 32.1667f, 16.2917f, 31.4583f, 16.875f, 30.875f) + curveTo(17.4583f, 30.2917f, 18.1667f, 30.0f, 19.0f, 30.0f) + curveTo(19.8333f, 30.0f, 20.5417f, 30.2917f, 21.125f, 30.875f) + curveTo(21.7083f, 31.4583f, 22.0f, 32.1667f, 22.0f, 33.0f) + curveTo(22.0f, 33.8333f, 21.7083f, 34.5417f, 21.125f, 35.125f) + curveTo(20.5417f, 35.7083f, 19.8333f, 36.0f, 19.0f, 36.0f) + close() + moveTo(25.95f, 26.0f) + verticalLineTo(22.45f) + lineTo(22.9f, 24.25f) + lineTo(20.9f, 20.75f) + lineTo(23.95f, 19.0f) + lineTo(20.9f, 17.25f) + lineTo(22.9f, 13.75f) + lineTo(25.95f, 15.55f) + verticalLineTo(12.0f) + horizontalLineTo(29.95f) + verticalLineTo(15.55f) + lineTo(33.0f, 13.75f) + lineTo(35.0f, 17.25f) + lineTo(31.95f, 19.0f) + lineTo(35.0f, 20.75f) + lineTo(33.0f, 24.25f) + lineTo(29.95f, 22.45f) + verticalLineTo(26.0f) + horizontalLineTo(25.95f) + close() + moveTo(38.15f, 38.05f) + lineTo(35.3f, 35.2f) + curveTo(36.8333f, 33.7f, 38.0f, 31.975f, 38.8f, 30.025f) + curveTo(39.6f, 28.075f, 40.0f, 26.0333f, 40.0f, 23.9f) + curveTo(40.0f, 21.7667f, 39.5917f, 19.725f, 38.775f, 17.775f) + curveTo(37.9583f, 15.825f, 36.8f, 14.1f, 35.3f, 12.6f) + lineTo(38.15f, 9.75f) + curveTo(40.0167f, 11.65f, 41.4583f, 13.8167f, 42.475f, 16.25f) + curveTo(43.4917f, 18.6833f, 44.0f, 21.2333f, 44.0f, 23.9f) + curveTo(44.0f, 26.5667f, 43.5f, 29.1167f, 42.5f, 31.55f) + curveTo(41.5f, 33.9833f, 40.05f, 36.15f, 38.15f, 38.05f) + close() + } + } + } + .build() + return _regularExpression!! + } + +private var _regularExpression: ImageVector? = null diff --git a/app/src/main/kotlin/com/dot/gallery/ui/theme/Theme.kt b/app/src/main/kotlin/com/dot/gallery/ui/theme/Theme.kt index 518cfee56d..905bac228b 100644 --- a/app/src/main/kotlin/com/dot/gallery/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/dot/gallery/ui/theme/Theme.kt @@ -5,10 +5,12 @@ package com.dot.gallery.ui.theme +import android.content.Context import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -17,6 +19,8 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import com.dot.gallery.core.Settings.Misc.rememberForceTheme @@ -93,36 +97,28 @@ private val DarkColors = darkColorScheme( fun GalleryTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ - dynamicColor: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, + dynamicColor: Boolean = remember { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + }, content: @Composable () -> Unit ) { val forceThemeValue by rememberForceTheme() val isDarkMode by rememberIsDarkMode() - val forcedDarkTheme: Boolean = if (forceThemeValue) isDarkMode else darkTheme + val forcedDarkTheme by remember(forceThemeValue, darkTheme, isDarkMode) { + mutableStateOf(if (forceThemeValue) isDarkMode else darkTheme) + } val isAmoledMode by rememberIsAmoledMode() - var colorScheme = when { - dynamicColor -> { - val context = LocalContext.current - if (forcedDarkTheme) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicDarkColorScheme(context) - } else { - DarkColors - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicLightColorScheme(context) + val context = LocalContext.current + val colorScheme = remember(dynamicColor, forcedDarkTheme, isAmoledMode) { + if (dynamicColor) { + maybeDynamicColorScheme(context, forcedDarkTheme, isAmoledMode) + } else { + if (forcedDarkTheme) { + DarkColors.maybeAmoled(isAmoledMode) } else { LightColors } } - - forcedDarkTheme -> DarkColors - else -> LightColors - } - if (forcedDarkTheme && isAmoledMode) { - colorScheme = colorScheme.copy( - surface = Color.Black, - inverseSurface = Color.White, - background = Color.Black - ) } MaterialTheme( @@ -134,4 +130,37 @@ fun GalleryTheme( content = content ) } +} + +private fun maybeDynamicColorScheme( + context: Context, + darkTheme: Boolean, + isAmoledMode: Boolean +): ColorScheme { + return if (darkTheme) { + if (atLeastS) { + dynamicDarkColorScheme(context).maybeAmoled(isAmoledMode) + } else { + DarkColors.maybeAmoled(isAmoledMode) + } + } else { + if (atLeastS) { + dynamicLightColorScheme(context) + } else { + LightColors + } + } +} + +private val atLeastS: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +private fun ColorScheme.maybeAmoled(boolean: Boolean) = if (boolean) { + copy( + surface = Color.Black, + inverseSurface = Color.White, + background = Color.Black + ) +} else { + this } \ No newline at end of file diff --git a/app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt b/app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt deleted file mode 100644 index 99a16cb77b..0000000000 --- a/app/src/main/kotlin/com/github/awxkee/avifcoil/decoder/Heif3Decoder.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2023 Radzivon Bartoshyk - * avif-coder [https://github.com/awxkee/avif-coder] - * - * Created by Radzivon Bartoshyk on 23/09/2023 - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ -// Same HeifDecoder but for coil3 - -package com.github.awxkee.avifcoil.decoder - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable -import android.os.Build -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.asImage -import coil3.decode.DecodeResult -import coil3.decode.Decoder -import coil3.fetch.SourceFetchResult -import coil3.request.Options -import coil3.request.allowRgb565 -import coil3.request.bitmapConfig -import coil3.size.Scale -import coil3.size.Size -import coil3.size.pxOrElse -import com.radzivon.bartoshyk.avif.coder.HeifCoder -import com.radzivon.bartoshyk.avif.coder.PreferredColorConfig -import com.radzivon.bartoshyk.avif.coder.ScaleMode -import kotlinx.coroutines.runInterruptible -import okio.ByteString.Companion.encodeUtf8 - -class HeifDecoder3( - context: Context?, - private val source: SourceFetchResult, - private val options: Options, -) : Decoder { - - private val coder = HeifCoder(context) - - @OptIn(ExperimentalCoilApi::class) - override suspend fun decode(): DecodeResult = runInterruptible { - // ColorSpace is preferred to be ignored due to lib is trying to handle all color profile by itself - val sourceData = source.source.source().readByteArray() - - var mPreferredColorConfig: PreferredColorConfig = when (options.bitmapConfig) { - Bitmap.Config.ALPHA_8 -> PreferredColorConfig.RGBA_8888 - Bitmap.Config.RGB_565 -> if (options.allowRgb565) PreferredColorConfig.RGB_565 else PreferredColorConfig.DEFAULT - Bitmap.Config.ARGB_8888 -> PreferredColorConfig.RGBA_8888 - else -> PreferredColorConfig.DEFAULT - } - if (options.bitmapConfig == Bitmap.Config.RGBA_F16) { - mPreferredColorConfig = PreferredColorConfig.RGBA_F16 - } else if (options.bitmapConfig == Bitmap.Config.HARDWARE) { - mPreferredColorConfig = PreferredColorConfig.HARDWARE - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && options.bitmapConfig == Bitmap.Config.RGBA_1010102) { - mPreferredColorConfig = PreferredColorConfig.RGBA_1010102 - } - - if (options.size == Size.ORIGINAL) { - val originalImage = - coder.decode( - sourceData, - preferredColorConfig = mPreferredColorConfig - ) - return@runInterruptible DecodeResult( - BitmapDrawable( - options.context.resources, - originalImage - ).asImage(), false - ) - } - - val dstWidth = options.size.width.pxOrElse { 0 } - val dstHeight = options.size.height.pxOrElse { 0 } - val scaleMode = when (options.scale) { - Scale.FILL -> ScaleMode.FILL - Scale.FIT -> ScaleMode.FIT - } - - val originalImage = - coder.decodeSampled( - sourceData, - dstWidth, - dstHeight, - preferredColorConfig = mPreferredColorConfig, - scaleMode, - ) - return@runInterruptible DecodeResult( - image = BitmapDrawable( - options.context.resources, - originalImage - ).asImage(), isSampled = true - ) - } - - /** - * @param context is preferred to be set when displaying an HDR content to apply Vulkan shaders - */ - class Factory(private val context: Context? = null) : Decoder.Factory { - override fun create( - result: SourceFetchResult, - options: Options, - imageLoader: ImageLoader - ): Decoder? { - return if (AVAILABLE_BRANDS.any { - result.source.source().rangeEquals(4, it) - }) HeifDecoder3(context, result, options) else null - } - - companion object { - private val MIF = "ftypmif1".encodeUtf8() - private val MSF = "ftypmsf1".encodeUtf8() - private val HEIC = "ftypheic".encodeUtf8() - private val HEIX = "ftypheix".encodeUtf8() - private val HEVC = "ftyphevc".encodeUtf8() - private val HEVX = "ftyphevx".encodeUtf8() - private val AVIF = "ftypavif".encodeUtf8() - private val AVIS = "ftypavis".encodeUtf8() - - private val AVAILABLE_BRANDS = listOf(MIF, MSF, HEIC, HEIX, HEVC, HEVX, AVIF, AVIS) - } - } -} diff --git a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt deleted file mode 100644 index 430981e612..0000000000 --- a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/CoilI3mageSource.kt +++ /dev/null @@ -1,259 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "CANNOT_OVERRIDE_INVISIBLE_MEMBER") - -package me.saket.telephoto.zoomable.coil3 - -import android.content.res.Resources -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.net.Uri -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalContext -import coil3.Image -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.asDrawable -import coil3.compose.LocalPlatformContext -import coil3.decode.DataSource -import coil3.gif.AnimatedImageDecoder -import coil3.imageLoader -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.request.ImageResult -import coil3.request.Options -import coil3.request.SuccessResult -import coil3.request.transitionFactory -import coil3.size.Dimension -import coil3.size.Precision -import coil3.size.SizeResolver -import coil3.svg.SvgDecoder -import coil3.transition.CrossfadeTransition -import com.dot.gallery.feature_node.presentation.util.JxlDecoder -import com.github.awxkee.avifcoil.decoder.HeifDecoder3 -import com.google.accompanist.drawablepainter.DrawablePainter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withContext -import me.saket.telephoto.subsamplingimage.ImageBitmapOptions -import me.saket.telephoto.subsamplingimage.SubSamplingImageSource -import me.saket.telephoto.zoomable.ZoomableImageSource -import me.saket.telephoto.zoomable.ZoomableImageSource.ResolveResult -import me.saket.telephoto.zoomable.coil3.Resolver.ImageSourceCreationResult.EligibleForSubSampling -import me.saket.telephoto.zoomable.coil3.Resolver.ImageSourceCreationResult.ImageDeletedOnlyFromDiskCache -import me.saket.telephoto.zoomable.internal.RememberWorker -import me.saket.telephoto.zoomable.internal.copy -import java.io.File -import kotlin.math.roundToInt -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import coil3.size.Size as CoilSize - -@Composable -fun ZoomableImageSource.Companion.coil3( - model: Any?, - imageLoader: ImageLoader = LocalPlatformContext.current.imageLoader -): ZoomableImageSource { - return remember(model, imageLoader) { - Coil3ImageSource(model, imageLoader) - } -} - -internal class Coil3ImageSource( - private val model: Any?, - private val imageLoader: ImageLoader, -) : ZoomableImageSource { - - @Composable - override fun resolve(canvasSize: Flow): ResolveResult { - val context = LocalContext.current - val resolver = remember(this) { - Resolver( - request = model as? ImageRequest - ?: ImageRequest.Builder(context) - .data(model) - .build(), - imageLoader = imageLoader, - sizeResolver = { canvasSize.first().toCoilSize() } - ) - } - return resolver.resolved - } - - private fun Size.toCoilSize() = CoilSize( - width = if (width.isFinite()) Dimension(width.roundToInt()) else Dimension.Undefined, - height = if (height.isFinite()) Dimension(height.roundToInt()) else Dimension.Undefined - ) -} - -internal class Resolver( - internal val request: ImageRequest, - internal val imageLoader: ImageLoader, - private val sizeResolver: SizeResolver, -) : RememberWorker() { - - internal var resolved: ResolveResult by mutableStateOf( - ResolveResult(delegate = null) - ) - - @OptIn(ExperimentalCoilApi::class) - override suspend fun work() { - val imageLoader = imageLoader - .newBuilder() - .components { - add(HeifDecoder3.Factory(request.context)) - // SVGs - add(SvgDecoder.Factory(false)) - add(JxlDecoder.Factory()) - // GIFs - add(AnimatedImageDecoder.Factory()) - }.build() - val result = imageLoader.execute( - request.newBuilder() - .size(request.defined.sizeResolver ?: sizeResolver) - // There's no easy way to be certain whether an image will require sub-sampling in - // advance so assume it'll be needed and force Coil to write this image to disk. - .diskCachePolicy( - when (request.diskCachePolicy) { - CachePolicy.ENABLED -> CachePolicy.ENABLED - CachePolicy.READ_ONLY -> CachePolicy.ENABLED - CachePolicy.WRITE_ONLY, - CachePolicy.DISABLED -> CachePolicy.WRITE_ONLY - } - ) - // This will unfortunately replace any existing target, but it is also the only - // way to read placeholder images set using ImageRequest#placeholderMemoryCacheKey. - // Placeholder images should be small in size so sub-sampling isn't needed here. - .target( - onStart = { - resolved = resolved.copy( - placeholder = it?.asPainter(request.context.resources), - ) - } - ) - // Increase memory cache hit rate because the image will anyway fit the canvas - // size at draw time. - .precision( - when (request.defined.precision) { - Precision.EXACT -> request.precision - else -> Precision.INEXACT - } - ) - .build() - ) - - val imageSource = when (val it = result.toSubSamplingImageSource()) { - null -> null - is EligibleForSubSampling -> it.source - is ImageDeletedOnlyFromDiskCache -> { - return - } - } - resolved = resolved.copy( - crossfadeDuration = result.crossfadeDuration(), - delegate = if (result is SuccessResult && imageSource != null) { - ZoomableImageSource.SubSamplingDelegate( - source = imageSource, - imageOptions = ImageBitmapOptions(from = result.asBitmap()!!) - ) - } else { - ZoomableImageSource.PainterDelegate( - painter = result.asDrawable()?.asPainter() - ) - }, - ) - } - - @OptIn(ExperimentalCoilApi::class) - private fun ImageResult.asDrawable() = image?.asDrawable(request.context.resources) - - @OptIn(ExperimentalCoilApi::class) - private fun ImageResult.asBitmap() = (image?.asDrawable(request.context.resources) as? BitmapDrawable)?.bitmap - - @OptIn(ExperimentalCoilApi::class) - private fun Image.asPainter(resources: Resources) = asDrawable(resources).asPainter() - - private sealed interface ImageSourceCreationResult { - data class EligibleForSubSampling(val source: SubSamplingImageSource) : ImageSourceCreationResult - - /** Image was deleted from the disk cache, but is still present in the memory cache. */ - data object ImageDeletedOnlyFromDiskCache : ImageSourceCreationResult - } - - @OptIn(ExperimentalCoilApi::class) - private suspend fun ImageResult.toSubSamplingImageSource(): ImageSourceCreationResult? { - val result = this - val source = if (result is SuccessResult && result.asDrawable() is BitmapDrawable) { - val preview = result.asBitmap()?.asImageBitmap() - when { - // Prefer reading of images directly from files whenever possible because - // it is significantly faster than reading from their input streams. - result.diskCacheKey != null -> { - val diskCache = imageLoader.diskCache!! - val snapshot = withContext(Dispatchers.IO) { // IO because openSnapshot() can delete files. - diskCache.openSnapshot(result.diskCacheKey!!) - } - if (snapshot == null) { - return when (result.dataSource) { - DataSource.MEMORY_CACHE -> ImageDeletedOnlyFromDiskCache - else -> error("Coil returned an image that is missing from its disk cache") - } - } - SubSamplingImageSource.file(snapshot.data, preview, onClose = snapshot::close) - } - result.dataSource.let { it == DataSource.DISK || it == DataSource.MEMORY_CACHE } -> { - // Possible reasons for reaching this code path: - // - Locally stored images such as assets, resource, etc. - // - Remote image that wasn't saved to disk because of a "no-store" HTTP header. - result.request.mapRequestDataToUriOrNull()?.let { uri -> - SubSamplingImageSource.contentUriOrNull(uri, preview) - } - } - - else -> { - // Image wasn't saved to the disk. Telephoto won't be able to load this image in its full - // quality. It'll attempt to display the bitmap directly as a fallback, but that can - // potentially cause an OutOfMemoryError when the bitmap is drawn. - return null - } - } - } else { - return null - } - return if (source?.canBeSubSampled() == true) EligibleForSubSampling( - source - ) else null - } - - - private fun ImageResult.crossfadeDuration(): Duration { - val transitionFactory = request.transitionFactory - return if (this is SuccessResult && transitionFactory is CrossfadeTransition.Factory) { - // I'm intentionally not using factory.create() because it optimizes crossfade duration - // to zero if the image was fetched from memory cache. SubSamplingImage will only read - // bitmaps from the disk so there will always be some delay in showing the image. - transitionFactory.durationMillis.milliseconds - } else { - Duration.ZERO - } - } - - private fun ImageRequest.mapRequestDataToUriOrNull(): Uri? { - val dummyOptions = Options(request.context) // Good enough for mappers that only use the context. - return when (val mapped = imageLoader.components.map(data, dummyOptions)) { - is Uri -> mapped - is File -> Uri.parse(mapped.path) - else -> null - } - } - - private fun Drawable.asPainter(): Painter { - return DrawablePainter(mutate()) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt deleted file mode 100644 index 15c4d89cae..0000000000 --- a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/canBeSubSampled.kt +++ /dev/null @@ -1,57 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package me.saket.telephoto.zoomable.coil3 - -import android.annotation.SuppressLint -import android.util.TypedValue -import coil3.decode.DecodeUtils -import coil3.gif.isGif -import coil3.svg.isSvg -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import me.saket.telephoto.subsamplingimage.AssetImageSource -import me.saket.telephoto.subsamplingimage.FileImageSource -import me.saket.telephoto.subsamplingimage.RawImageSource -import me.saket.telephoto.subsamplingimage.ResourceImageSource -import me.saket.telephoto.subsamplingimage.SubSamplingImageSource -import me.saket.telephoto.subsamplingimage.UriImageSource -import okio.FileSystem -import okio.Source -import okio.buffer -import okio.source -import okio.use - -context(Resolver) -internal suspend fun SubSamplingImageSource.canBeSubSampled(): Boolean { - return withContext(Dispatchers.IO) { - when (this@canBeSubSampled) { - is ResourceImageSource -> !isVectorDrawable() - is AssetImageSource -> canBeSubSampled() - is UriImageSource -> canBeSubSampled() - is FileImageSource -> canBeSubSampled(FileSystem.SYSTEM.source(path)) - is RawImageSource -> canBeSubSampled(source.invoke()) - } - } -} - -context(Resolver) -private fun ResourceImageSource.isVectorDrawable(): Boolean = - TypedValue().apply { - request.context.resources.getValue(id, this, /* resolveRefs = */ true) - }.string.endsWith(".xml") - -context(Resolver) -private fun AssetImageSource.canBeSubSampled(): Boolean = - canBeSubSampled(peek(request.context).source()) - -context(Resolver) -@SuppressLint("Recycle") -private fun UriImageSource.canBeSubSampled(): Boolean = - canBeSubSampled(peek(request.context).source()) - -private fun canBeSubSampled(source: Source): Boolean { - return source.buffer().use { - // Check for GIFs as well because Android's ImageDecoder can return a Bitmap for single-frame GIFs. - !DecodeUtils.isSvg(it) && !DecodeUtils.isGif(it) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt b/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt deleted file mode 100644 index 20107c11dc..0000000000 --- a/app/src/main/kotlin/me/saket/telephoto/zoomable/coil3/imageFormats.kt +++ /dev/null @@ -1,55 +0,0 @@ -package me.saket.telephoto.zoomable.coil3 - -import coil.decode.DecodeUtils -import okio.BufferedSource -import okio.ByteString -import okio.ByteString.Companion.encodeUtf8 - -private val SVG_TAG: ByteString = " 0) { "bytes is empty" } - - val firstByte = bytes[0] - val lastIndex = toIndex - bytes.size - var currentIndex = fromIndex - while (currentIndex < lastIndex) { - currentIndex = indexOf(firstByte, currentIndex, lastIndex) - if (currentIndex == -1L || rangeEquals(currentIndex, bytes)) { - return currentIndex - } - currentIndex++ - } - return -1 -} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000000..72758a2913 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rounded_border_tv.xml b/app/src/main/res/drawable/rounded_border_tv.xml new file mode 100644 index 0000000000..e826b5f50e --- /dev/null +++ b/app/src/main/res/drawable/rounded_border_tv.xml @@ -0,0 +1,15 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_photo_editor_image.xml b/app/src/main/res/layout/view_photo_editor_image.xml new file mode 100644 index 0000000000..4566745da5 --- /dev/null +++ b/app/src/main/res/layout/view_photo_editor_image.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_photo_editor_text.xml b/app/src/main/res/layout/view_photo_editor_text.xml new file mode 100644 index 0000000000..2890d4e7cc --- /dev/null +++ b/app/src/main/res/layout/view_photo_editor_text.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..6532d08b5a --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5815b1b86..de6463cb55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,6 +191,65 @@ Delete Vault Unknown Vault Hide + Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a wildcard pattern to automatically hide any albums that matches it. + No ignored albums + Setup ignored albums + Individual Album Selection + Wildcard Creator + The wildcard will be used to match album names or their paths.\nThe wildcard can be treated as a regex pattern. + Wildcard: %1$s\nMatched albums: %2$s + "Matched albums: %1$s" + Go back + Continue + Apply + Select what albums you would like to not see in the main Timeline, in the Albums tab or both.\n\nYou can either select individual albums or create a Regex Pattern to automatically hide any albums that matches it. + Label + Set your label + Ignored Albums + Create + Options + Where and who’s ignored + Where should be ignored + The album and it’s content will be hidden depending where the location is set + Albums + Timeline + Both + Who should be ignored + You can select either a single album or create a Regex pattern to match multiple + Selection + Regex Pattern + Selection + Select your to-be-ignored album + Add album + Pick an album + Regex Pattern + Ignore your albums automatically using a regular expression pattern matching.\n\nBefore continuing your pattern must be valid. + Regex Pattern + Create a selection pattern + Examples + Match any album directly under the Music folder: + .*/Music/[^/]+/?$ + Match any album under the Music folder and its subfolders: + .*/Music/.+/[^/]+/?$ + Match any album starting with Album_ in the Music folder: + .*/Music/Album_[^/]+/?$ + Invalid Regex Pattern + This wildcard is already in use + Confirmation + Overview your ignore options and matches + Where should be ignored + Who should be ignored + Matched Albums + Both Albums and Timeline + Created + Date + Name + + Hide search bar on scroll + Automatically hide the search bar or the top bar of the screen while scrolling + Hide navigation bar on scroll + Automatically hide the navigation bar while scrolling + Navigation %s item %s items diff --git a/build.gradle.kts b/build.gradle.kts index ff289691e8..70650f478a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,4 +9,5 @@ plugins { alias(libs.plugins.androidTest) apply false alias(libs.plugins.baselineProfilePlugin) apply false alias(libs.plugins.kotlin.compose.compiler) apply false + alias(libs.plugins.kotlinSerialization) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70ad0c6c2a..e94f7b4697 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,12 +4,13 @@ avifCoderCoil = "1.8.0" benchmarkMacroJunit4 = "1.2.4" biometric = "1.2.0-alpha05" appcompat = "1.7.0" -coilVersion = "3.0.0-alpha08" composeVideo = "1.2.0" +core = "1.8.0" coreSplashscreen = "1.0.1" fuzzywuzzy = "1.4.0" exifinterface = "1.3.7" gpuimage = "2.1.0" +graphicsShapes = "1.0.0-rc01" jxlCoder = "2.1.10" kotlin = "2.0.0" kotlinCoroutinesVersion = "1.8.1" @@ -23,7 +24,7 @@ activity-compose = "1.9.1" compose-bom = "2024.06.00" hilt = "2.51" material = "1.12.0" -material3 = "1.2.1" +material3 = "1.3.0-beta05" media3Exoplayer = "1.4.0" navigation-runtime-ktx = "2.7.7" pinchzoomgrid = "0.0.5" @@ -32,10 +33,11 @@ room = "2.6.1" accompanist = "0.34.0" datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" +sketch = "4.0.0-alpha06" uiautomator = "2.3.0" -zoomable = "1.6.1" -zoomableImageCoil = "0.12.1" -composealpha = "1.7.0-beta06" +composealpha = "1.7.0-beta07" +kotlinxSerializationJson = "1.7.0" +zoomimageViewSketch = "1.1.0-alpha05" [libraries] # AndroidX @@ -45,6 +47,7 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } +androidx-graphics-shapes = { module = "androidx.graphics:graphics-shapes", version.ref = "graphicsShapes" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3Exoplayer" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3Exoplayer" } @@ -58,11 +61,6 @@ androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstall androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } avif-coder-coil = { module = "com.github.awxkee:avif-coder-coil", version.ref = "avifCoderCoil" } -coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coilVersion" } -coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } -coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilVersion" } -coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } -coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilVersion" } # Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } @@ -86,6 +84,7 @@ compose-shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } # Dagger +core = { module = "com.composables:core", version.ref = "core" } dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.2.0" } @@ -107,7 +106,13 @@ accompanist-permissions = { group = "com.google.accompanist", name = "accompanis accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist" } # Zoomable -zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } +sketch-animated = { module = "io.github.panpf.sketch4:sketch-animated", version.ref = "sketch" } +sketch-compose = { module = "io.github.panpf.sketch4:sketch-compose", version.ref = "sketch" } +sketch-extensions-compose = { module = "io.github.panpf.sketch4:sketch-extensions-compose", version.ref = "sketch" } +sketch-http-ktor = { module = "io.github.panpf.sketch4:sketch-http-ktor", version.ref = "sketch" } +sketch-svg = { module = "io.github.panpf.sketch4:sketch-svg", version.ref = "sketch" } +sketch-video = { module = "io.github.panpf.sketch4:sketch-video-ffmpeg", version.ref = "sketch" } +sketch-view = { module = "io.github.panpf.sketch4:sketch-view", version.ref = "sketch" } # Kotlinx kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinCoroutinesVersion" } @@ -123,7 +128,9 @@ fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } -zoomable-image-coil = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "zoomableImageCoil" } + +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +zoomimage-sketch = { module = "io.github.panpf.zoomimage:zoomimage-compose-sketch", version.ref = "zoomimageViewSketch" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -135,5 +142,6 @@ roomPlugin = { id = "androidx.room", version.ref = "room" } hiltAndroid = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } baselineProfilePlugin = { id = "androidx.baselineprofile", version.ref = "benchmarkMacroJunit4" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [bundles] diff --git a/settings.gradle.kts b/settings.gradle.kts index e12e6b570d..d4bd39e11d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ pluginManagement { mavenCentral() gradlePluginPortal() maven("https://jitpack.io") + mavenLocal() } } dependencyResolutionManagement { @@ -12,6 +13,7 @@ dependencyResolutionManagement { google() mavenCentral() maven("https://jitpack.io") + mavenLocal() } } rootProject.name = "Gallery"