diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4ec2e3dc..8b6aa1d53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,7 +21,7 @@ android { applicationId = "com.dot.gallery" minSdk = 30 targetSdk = 35 - versionCode = 30024 + versionCode = 30033 versionName = "3.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -132,7 +132,6 @@ dependencies { // Compose implementation(libs.compose.activity) - implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) @@ -144,6 +143,9 @@ dependencies { // Compose - Material3 implementation(libs.compose.material3) implementation(libs.compose.material3.window.size) + implementation(libs.androidx.adaptive) + implementation(libs.androidx.adaptive.layout) + implementation(libs.androidx.adaptive.navigation) // Compose - Accompanists implementation(libs.accompanist.permissions) @@ -162,6 +164,8 @@ dependencies { // Dagger - Hilt implementation(libs.androidx.hilt.navigation.compose) implementation(libs.dagger.hilt) + implementation(libs.androidx.hilt.common) + implementation(libs.androidx.hilt.work) ksp(libs.dagger.hilt.compiler) ksp(libs.androidx.hilt.compiler) @@ -221,10 +225,16 @@ dependencies { // Composables - Core implementation(libs.core) + // Worker + implementation(libs.androidx.work.runtime.ktx) + + // Composable - Scrollbar + implementation(libs.lazycolumnscrollbar) + + // Tests testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(platform(libs.compose.bom)) debugImplementation(libs.compose.ui.tooling) debugRuntimeOnly(libs.compose.ui.test.manifest) } 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 index 741cc9a74..57b91fbe9 100644 --- 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 @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 4, - "identityHash": "b923fda23747db68de4db23fb2360ff8", + "identityHash": "d11e7f0b804e55f9526cc63d181876a1", "entities": [ { "tableName": "pinned_table", @@ -69,12 +69,142 @@ }, "indices": [], "foreignKeys": [] + }, + { + "tableName": "media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relativePath", + "columnName": "relativePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumID", + "columnName": "albumID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumLabel", + "columnName": "albumLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryTimestamp", + "columnName": "expiryTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "takenTimestamp", + "columnName": "takenTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fullDate", + "columnName": "fullDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashed", + "columnName": "trashed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "media_version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", + "fields": [ + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "version" + ] + }, + "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')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd11e7f0b804e55f9526cc63d181876a1')" ] } } \ No newline at end of file diff --git a/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/5.json b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/5.json new file mode 100644 index 000000000..3c4cb0059 --- /dev/null +++ b/app/schemas/com.dot.gallery.feature_node.data.data_source.InternalDatabase/5.json @@ -0,0 +1,258 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "65c73e58b535f1a24397f4143bdf1dde", + "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": [] + }, + { + "tableName": "media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `label` TEXT NOT NULL, `uri` TEXT NOT NULL, `path` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `albumID` INTEGER NOT NULL, `albumLabel` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `expiryTimestamp` INTEGER, `takenTimestamp` INTEGER, `fullDate` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `favorite` INTEGER NOT NULL, `trashed` INTEGER NOT NULL, `size` INTEGER NOT NULL, `duration` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relativePath", + "columnName": "relativePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumID", + "columnName": "albumID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumLabel", + "columnName": "albumLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryTimestamp", + "columnName": "expiryTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "takenTimestamp", + "columnName": "takenTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fullDate", + "columnName": "fullDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trashed", + "columnName": "trashed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "media_version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`version` TEXT NOT NULL, PRIMARY KEY(`version`))", + "fields": [ + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "version" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "timeline_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupTimelineByMonth` INTEGER NOT NULL DEFAULT 0, `groupTimelineInAlbums` INTEGER NOT NULL DEFAULT 0, `timelineMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}', `albumMediaOrder` TEXT NOT NULL DEFAULT '{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupTimelineByMonth", + "columnName": "groupTimelineByMonth", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "groupTimelineInAlbums", + "columnName": "groupTimelineInAlbums", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timelineMediaOrder", + "columnName": "timelineMediaOrder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" + }, + { + "fieldPath": "albumMediaOrder", + "columnName": "albumMediaOrder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}'" + } + ], + "primaryKey": { + "autoGenerate": true, + "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, '65c73e58b535f1a24397f4143bdf1dde')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a3846560..d8a241b07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -173,6 +173,11 @@ android:resource="@xml/filepaths" /> + + \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt b/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt index 17ff90ee9..6d2c8d188 100644 --- a/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt +++ b/app/src/main/kotlin/com/dot/gallery/GalleryApp.kt @@ -6,13 +6,14 @@ package com.dot.gallery import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration 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 @@ -24,15 +25,17 @@ import com.github.panpf.sketch.request.supportSaveCellularTraffic import com.github.panpf.sketch.util.appCacheDirectory import dagger.hilt.android.HiltAndroidApp import okio.FileSystem +import javax.inject.Inject @HiltAndroidApp -class GalleryApp : Application(), SingletonSketch.Factory { +class GalleryApp : Application(), SingletonSketch.Factory, Configuration.Provider { override fun createSketch(context: PlatformContext): Sketch = Sketch.Builder(this).apply { httpStack(KtorStack()) components { supportSaveCellularTraffic() supportPauseLoadWhenScrolling() + //supportThumbnailDecoder() supportSvg() supportVideoFrame() supportAnimatedGif() @@ -47,7 +50,14 @@ class GalleryApp : Application(), SingletonSketch.Factory { resultCache(diskCache) downloadCache(diskCache) - memoryCache(MemoryCache.Builder(context).maxSizePercent(0.75).build()) }.build() + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .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 a7c0620f5..65aa2e0d9 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/Constants.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/Constants.kt @@ -107,6 +107,8 @@ object Constants { } val cellsList = listOf( + GridCells.Fixed(9), + GridCells.Fixed(8), GridCells.Fixed(7), GridCells.Fixed(6), GridCells.Fixed(5), diff --git a/app/src/main/kotlin/com/dot/gallery/core/DatabaseUpdaterWorker.kt b/app/src/main/kotlin/com/dot/gallery/core/DatabaseUpdaterWorker.kt new file mode 100644 index 000000000..fa0988250 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/DatabaseUpdaterWorker.kt @@ -0,0 +1,63 @@ +package com.dot.gallery.core + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.dot.gallery.feature_node.data.data_source.InternalDatabase +import com.dot.gallery.feature_node.domain.model.MediaVersion +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.presentation.util.isMediaUpToDate +import com.dot.gallery.feature_node.presentation.util.mediaStoreVersion +import com.dot.gallery.feature_node.presentation.util.printDebug +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.withContext + +fun WorkManager.updateDatabase() { + val uniqueWork = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiresStorageNotLow(true) + .build() + ) + .build() + + enqueueUniqueWork("DatabaseUpdaterWorker", ExistingWorkPolicy.KEEP, uniqueWork) +} + +@HiltWorker +class DatabaseUpdaterWorker @AssistedInject constructor( + private val database: InternalDatabase, + private val repository: MediaRepository, + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters +) : + CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + if (database.isMediaUpToDate(appContext)) { + printDebug("Database is up to date") + return Result.success() + } + withContext(Dispatchers.IO) { + val mediaVersion = appContext.mediaStoreVersion + printDebug("Database is not up to date. Updating to version $mediaVersion") + database.getMediaDao().setMediaVersion(MediaVersion(mediaVersion)) + val media = repository.getMedia().map { it.data ?: emptyList() }.single() + database.getMediaDao().updateMedia(media) + delay(5000) + } + + return Result.success() + } +} + 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 3de2144e9..4c5ce2610 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt @@ -6,51 +6,14 @@ package com.dot.gallery.core 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 -private var observerJob: Job? = null -/** - * Register an observer class that gets callbacks when data identified by a given content URI - * changes. - */ -fun Context.contentFlowObserver(uris: Array) = callbackFlow { - val observer = object : ContentObserver(null) { - override fun onChange(selfChange: Boolean) { - observerJob?.cancel() - observerJob = launch(Dispatchers.IO) { - send(false) - } - } - } - for (uri in uris) - contentResolver.registerContentObserver(uri, true, observer) - // trigger first. - observerJob = launch(Dispatchers.IO) { - send(true) - } - awaitClose { - contentResolver.unregisterContentObserver(observer) - } -}.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 { val observer = object : FileObserver(filesDir, CREATE or DELETE or MODIFY or MOVED_FROM or MOVED_TO) { @@ -68,4 +31,4 @@ fun Context.fileFlowObserver() = callbackFlow { awaitClose { observer.stopWatching() } -} \ No newline at end of file +}.conflate() diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt deleted file mode 100644 index 496fe0cf9..000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.core - -import android.os.Parcelable -import androidx.compose.runtime.Immutable -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.EncryptedMedia -import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.MediaItem -import kotlinx.parcelize.Parcelize - -@Immutable -@Parcelize -data class MediaState( - val media: List = emptyList(), - val mappedMedia: List = emptyList(), - val mappedMediaWithMonthly: List = emptyList(), - val dateHeader: String = "", - val error: String = "", - val isLoading: Boolean = true -) : Parcelable - - -@Immutable -@Parcelize -data class AlbumState( - val albums: List = emptyList(), - val error: String = "" -) : Parcelable - -@Immutable -@Parcelize -data class EncryptedMediaState( - val media: List = emptyList(), - val mappedMedia: List = emptyList(), - val mappedMediaWithMonthly: List = emptyList(), - val dateHeader: String = "", - val error: String = "", - val isLoading: Boolean = true -) : Parcelable \ No newline at end of file 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 f0341d8b6..314f1fa32 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/Settings.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/Settings.kt @@ -5,11 +5,16 @@ package com.dot.gallery.core +import android.app.Activity import android.content.Context import android.os.Build import android.os.Parcelable import android.provider.MediaStore import androidx.annotation.RequiresApi +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember @@ -23,6 +28,8 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.dot.gallery.core.Constants.albumCellsList +import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.Settings.PREFERENCE_NAME import com.dot.gallery.core.presentation.components.FilterKind import com.dot.gallery.core.util.rememberPreference @@ -47,12 +54,16 @@ object Settings { data class LastSort( val orderType: OrderType, val kind: FilterKind - ): Parcelable + ) : Parcelable @Composable fun rememberLastSort() = - rememberPreference(key = LAST_SORT, defaultValue = LastSort(OrderType.Descending, FilterKind.DATE)) + rememberPreference( + key = LAST_SORT, + defaultValue = LastSort(OrderType.Descending, FilterKind.DATE) + ) + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberAlbumGridSize(): MutableState { val scope = rememberCoroutineScope() @@ -60,8 +71,21 @@ object Settings { val prefs = remember(context) { context.getSharedPreferences("ui_settings", Context.MODE_PRIVATE) } + + val windowSizeClass = if (context is Activity) calculateWindowSizeClass(context) else null + val defaultValue = remember(windowSizeClass) { + albumCellsList.indexOf( + GridCells.Fixed( + when (windowSizeClass?.widthSizeClass) { + WindowWidthSizeClass.Expanded -> 5 + else -> 2 + } + ) + ) + } + var storedSize = remember(prefs) { - prefs.getInt("album_grid_size", 5) + prefs.getInt("album_grid_size", defaultValue) } return remember(storedSize) { @@ -131,6 +155,7 @@ object Settings { fun rememberForcedLastScreen() = rememberPreference(key = FORCED_LAST_SCREEN, defaultValue = false) + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberGridSize(): MutableState { val scope = rememberCoroutineScope() @@ -138,8 +163,20 @@ object Settings { val prefs = remember(context) { context.getSharedPreferences("ui_settings", Context.MODE_PRIVATE) } + val windowSizeClass = if (context is Activity) calculateWindowSizeClass(context) else null + val defaultValue = remember(windowSizeClass) { + cellsList.indexOf( + GridCells.Fixed( + when (windowSizeClass?.widthSizeClass) { + WindowWidthSizeClass.Expanded -> 6 + else -> 4 + } + ) + ) + } + var storedSize = remember(prefs) { - prefs.getInt("media_grid_size", 3) + prefs.getInt("media_grid_size", defaultValue) } return remember(storedSize) { 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 index 03a51d27c..d0cc43c86 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchHeifDecoder.kt @@ -1,9 +1,5 @@ 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 @@ -13,23 +9,12 @@ 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.buffer -import java.nio.ByteBuffer fun ComponentRegistry.Builder.supportHeifDecoder(): ComponentRegistry.Builder = apply { addDecoder(SketchHeifDecoder.Factory()) @@ -38,18 +23,17 @@ fun ComponentRegistry.Builder.supportHeifDecoder(): ComponentRegistry.Builder = class SketchHeifDecoder( private val requestContext: RequestContext, private val dataSource: DataSource, + private val mimeType: String ) : 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? { - return if (fetchResult.mimeType in AVAILABLE_MIME_TYPES) { - SketchHeifDecoder(requestContext, fetchResult.dataSource) + return if (HEIF_MIMETYPES.any { fetchResult.mimeType?.contains(it) == true }) { + SketchHeifDecoder(requestContext, fetchResult.dataSource, fetchResult.mimeType!!) } else { null } @@ -67,7 +51,7 @@ class SketchHeifDecoder( override fun toString(): String = key companion object { - private val AVAILABLE_MIME_TYPES = listOf( + val HEIF_MIMETYPES = listOf( "image/heif", "image/heic", "image/heif-sequence", @@ -79,128 +63,60 @@ class SketchHeifDecoder( } override suspend fun decode(): Result = runCatching { - val request = requestContext.request + val coder = HeifCoder(requestContext.request.context) dataSource.openSource().use { src -> val sourceData = src.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) + val request = requestContext.request - 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 - } + val imageInfo: ImageInfo - 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 size: Size - val resize = requestContext.computeResize(imageInfo!!.size) - val originalImage = + val decodedImage = if (requestContext.size == Size.Origin) { + val originalImageBitmap = coder.decode(sourceData) + size = Size(originalImageBitmap.width, originalImageBitmap.height) + imageInfo = ImageInfo( + width = size.width, + height = size.height, + mimeType = mimeType, + ) + originalImageBitmap + } else { + val resize = requestContext.computeResize(requestContext.size!!) + size = resize.size + imageInfo = ImageInfo( + width = size.width, + height = size.height, + mimeType = mimeType, + ) coder.decodeSampled( sourceData, - resize.size.width, - resize.size.height, - preferredColorConfig = mPreferredColorConfig, - scaleMode = ScaleMode.FIT, + size.width, + size.height, + preferredColorConfig = PreferredColorConfig.RGBA_8888 ) + } + + val precision = request.precisionDecider.get( + imageSize = Size(imageInfo.size.width, imageInfo.size.height), + targetSize = size, + ) + val inSampleSize = calculateSampleSize( + imageSize = Size(imageInfo.size.width, imageInfo.size.height), + targetSize = size, + smallerSizeMode = precision.isSmallerSizeMode() + ) DecodeResult( - image = originalImage.asSketchImage(), - imageInfo = imageInfo!!, + image = decodedImage.asSketchImage(), + imageInfo = imageInfo, dataFrom = dataSource.dataFrom, - resize = resize, - transformeds = transformeds, + resize = requestContext.computeResize(if (size == Size.Origin) requestContext.size!! else size), + transformeds = if (inSampleSize != 1) listOf(createInSampledTransformed(inSampleSize)) else null, 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 index 779c5a79c..a06960067 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/SketchJxlDecoder.kt @@ -1,10 +1,6 @@ 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 @@ -14,19 +10,10 @@ 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.buffer -import java.nio.ByteBuffer fun ComponentRegistry.Builder.supportJxlDecoder(): ComponentRegistry.Builder = apply { addDecoder(SketchJxlDecoder.Factory()) @@ -43,7 +30,7 @@ class SketchJxlDecoder( get() = "JxlDecoder" override fun create(requestContext: RequestContext, fetchResult: FetchResult): Decoder? { - return if (fetchResult.mimeType in AVAILABLE_MIME_TYPES) { + return if (fetchResult.mimeType?.contains(JXL_MIMETYPE) == true) { SketchJxlDecoder(requestContext, fetchResult.dataSource) } else { null @@ -62,117 +49,57 @@ class SketchJxlDecoder( override fun toString(): String = key companion object { - private val AVAILABLE_MIME_TYPES = listOf( - "image/jxl" - ) + const val JXL_MIMETYPE = "image/jxl" } + } 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 + val originalImageBitmap = JxlCoder.decode(sourceData) + var imageInfo = ImageInfo( + width = originalImageBitmap.width, + height = originalImageBitmap.height, + mimeType = "image/jxl", + ) - 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 decodedImage = if (requestContext.size == Size.Origin) { + originalImageBitmap + } else { + imageInfo = ImageInfo( + width = resize.size.width, + height = resize.size.height, + mimeType = "image/jxl", ) - } - - 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 + resize.size.height ) + } + val size = requestContext.size!! + val precision = request.precisionDecider.get( + imageSize = Size(imageInfo.size.width, imageInfo.size.height), + targetSize = size, + ) + val inSampleSize = calculateSampleSize( + imageSize = Size(imageInfo.size.width, imageInfo.size.height), + targetSize = size, + smallerSizeMode = precision.isSmallerSizeMode() + ) + val transformeds: List? = + if (inSampleSize != 1) listOf(createInSampledTransformed(inSampleSize)) else null DecodeResult( - image = originalImage.asSketchImage(), - imageInfo = imageInfo!!, + image = decodedImage.asSketchImage(), + imageInfo = imageInfo, dataFrom = dataSource.dataFrom, - resize = resize, + resize = requestContext.computeResize(if (size == Size.Origin) requestContext.size!! else size), 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 index 3861168ed..c30e6ab09 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/decoder/ThumbnailDecoder.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/decoder/ThumbnailDecoder.kt @@ -1,7 +1,7 @@ -/*package com.dot.gallery.core.decoder +package com.dot.gallery.core.decoder -import coil3.decode.DecodeUtils -import coil3.svg.isSvg +import com.dot.gallery.core.decoder.SketchHeifDecoder.Factory.Companion.HEIF_MIMETYPES +import com.dot.gallery.core.decoder.SketchJxlDecoder.Factory.Companion.JXL_MIMETYPE import com.github.panpf.sketch.ComponentRegistry import com.github.panpf.sketch.asSketchImage import com.github.panpf.sketch.decode.DecodeResult @@ -16,7 +16,6 @@ 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 { @@ -39,6 +38,7 @@ class ThumbnailDecoder( mimeType != null && mimeType.isVideoOrImage && !isSvg(fetchResult) && + !isSpecialFormat(fetchResult) && fetchResult.dataSource is ContentDataSource ) ThumbnailDecoder(requestContext, fetchResult.dataSource) @@ -59,13 +59,13 @@ class ThumbnailDecoder( 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() - ) + result.mimeType?.contains(MIME_TYPE_SVG) == true + private fun isSpecialFormat(result: FetchResult) = + HEIF_MIMETYPES.any { result.mimeType?.contains(it) == true } || result.mimeType?.contains(JXL_MIMETYPE) == true companion object { - private const val MIME_TYPE_SVG = "image/svg+xml" + private const val MIME_TYPE_SVG = "image/svg" } } @@ -106,4 +106,3 @@ class ThumbnailDecoder( } } -*/ \ 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 e9a2257aa..012010f87 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 @@ -5,6 +5,7 @@ package com.dot.gallery.core.presentation.components +import android.app.Activity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState @@ -41,17 +42,22 @@ import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -67,6 +73,8 @@ 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 import com.dot.gallery.ui.core.icons.Albums +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun rememberNavigationItems(): List { @@ -94,16 +102,18 @@ fun rememberNavigationItems(): List { } } +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Stable @Composable fun AppBarContainer( - windowSizeClass: WindowSizeClass, navController: NavController, bottomBarState: Boolean, paddingValues: PaddingValues, isScrolling: Boolean, content: @Composable () -> Unit, ) { + val context = LocalContext.current + val windowSizeClass = calculateWindowSizeClass(context as Activity) val backStackEntry by navController.currentBackStackEntryAsState() val bottomNavItems = rememberNavigationItems() val useNavRail by remember(windowSizeClass) { @@ -141,9 +151,16 @@ fun AppBarContainer( content() } val hideNavBarSetting by rememberAutoHideNavBar() - val showClassicNavbar by remember(useNavRail, isScrolling, bottomBarState, hideNavBarSetting) { + var showClassicNavbar by remember { mutableStateOf(!useNavRail && bottomBarState && (!isScrolling || !hideNavBarSetting)) } + LaunchedEffect(useNavRail, isScrolling, bottomBarState, hideNavBarSetting) { + snapshotFlow { + !useNavRail && bottomBarState && (!isScrolling || !hideNavBarSetting) + }.distinctUntilChanged().collectLatest { + showClassicNavbar = it + } + } AnimatedVisibility( modifier = Modifier.align(Alignment.BottomCenter), visible = showClassicNavbar, @@ -167,9 +184,16 @@ fun AppBarContainer( Box(modifier = Modifier.fillMaxSize()) { content() val hideNavBarSetting by rememberAutoHideNavBar() - val showNavbar by remember(bottomBarState, isScrolling, hideNavBarSetting) { + var showNavbar by remember(bottomBarState, isScrolling, hideNavBarSetting) { mutableStateOf(bottomBarState && (!isScrolling || !hideNavBarSetting)) } + LaunchedEffect(bottomBarState, isScrolling, hideNavBarSetting) { + snapshotFlow { + bottomBarState && (!isScrolling || !hideNavBarSetting) + }.distinctUntilChanged().collectLatest { + showNavbar = it + } + } AnimatedVisibility( modifier = Modifier .align(Alignment.BottomEnd) diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyAlbum.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyAlbum.kt new file mode 100644 index 000000000..f94ca92ff --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/EmptyAlbum.kt @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.presentation.components + +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.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 + +@Composable +fun EmptyAlbum( + modifier: Modifier = Modifier, + title: String = stringResource(R.string.no_media_title), +) = LoadingAlbum( + modifier = modifier, + shouldShimmer = false, + bottomContent = { + Text( + modifier = Modifier.fillMaxWidth().padding(32.dp), + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + } +) \ No newline at end of file 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 259511175..de17d31d6 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,7 +5,6 @@ package com.dot.gallery.core.presentation.components -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -20,15 +19,13 @@ import com.dot.gallery.R @Composable fun EmptyMedia( modifier: Modifier = Modifier, - paddingValues: PaddingValues = PaddingValues(16.dp), title: String = stringResource(R.string.no_media_title), ) = LoadingMedia( modifier = modifier, - paddingValues = paddingValues, shouldShimmer = false, - topContent = { + bottomContent = { Text( - modifier = Modifier.fillMaxWidth().padding(16.dp).padding(top = paddingValues.calculateTopPadding() / 2), + modifier = Modifier.fillMaxWidth().padding(32.dp), text = title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingAlbum.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingAlbum.kt new file mode 100644 index 000000000..0df319096 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/LoadingAlbum.kt @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.presentation.components + +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.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.Constants.albumCellsList +import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize +import com.dot.gallery.ui.theme.Dimens +import com.valentinilk.shimmer.shimmer + +@Composable +fun LoadingAlbum( + modifier: Modifier = Modifier, + shouldShimmer: Boolean = true, + bottomContent: @Composable (() -> Unit)? = null, +) { + val gridSize by rememberAlbumGridSize() + val grid = remember(gridSize) { albumCellsList.size - gridSize } + val shape = remember { RoundedCornerShape(16.dp) } + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 48.dp) + .then(if (shouldShimmer) Modifier.shimmer() else Modifier), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(2) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(grid) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Album()) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = shape + ) + ) + } + } + } + if (shouldShimmer) { + repeat(4) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + repeat(grid) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Album()) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = shape + ) + ) + } + } + } + } + + if (bottomContent != null) { + bottomContent() + } + } +} \ 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 e25bca479..62b6f4104 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 @@ -8,7 +8,8 @@ package com.dot.gallery.core.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -16,13 +17,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.dot.gallery.core.Constants.cellsList @@ -33,28 +33,73 @@ import com.valentinilk.shimmer.shimmer @Composable fun LoadingMedia( modifier: Modifier = Modifier, - paddingValues: PaddingValues, shouldShimmer: Boolean = true, - topContent: @Composable (() -> Unit)? = null, + bottomContent: @Composable (() -> Unit)? = null, ) { - val gridState = rememberLazyGridState() val gridSize by rememberGridSize() - LazyVerticalGrid( - state = gridState, + val grid = remember(gridSize) { cellsList.size - gridSize } + Column( modifier = modifier .fillMaxSize() .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() + Box( + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 24.dp), + ) { + Spacer( + modifier = Modifier + .height(24.dp) + .fillMaxWidth(0.45f) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(100) + ) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(grid) { + Spacer( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Photo()) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + repeat(grid / 2) { + Spacer( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Photo()) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + repeat(grid / 2) { + Spacer( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Photo()) + ) } } - item(span = { GridItemSpan(maxLineSpan) }) { + if (shouldShimmer) { Box( modifier = Modifier .padding(horizontal = 24.dp, vertical = 24.dp) @@ -69,44 +114,52 @@ fun LoadingMedia( ) ) } - } - items(count = 10) { - Box( - modifier = Modifier - .aspectRatio(1f) - .size(Dimens.Photo()) - .background( - color = MaterialTheme.colorScheme.surfaceVariant - ) - ) - } - if (shouldShimmer) { - item(span = { GridItemSpan(maxLineSpan) }) { - Box( - modifier = Modifier - .padding(horizontal = 24.dp, vertical = 24.dp) + repeat(10) { + Row( + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalAlignment = Alignment.CenterVertically ) { + repeat(grid) { + Spacer( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Photo()) + .background( + color = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + repeat(grid / 2) { Spacer( modifier = Modifier - .height(24.dp) - .fillMaxWidth(0.35f) + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Photo()) .background( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - shape = RoundedCornerShape(100) + color = MaterialTheme.colorScheme.surfaceVariant ) ) } + repeat(grid / 2) { + Spacer( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .size(Dimens.Photo()) + ) + } } - items(count = 25) { - Box( - modifier = Modifier - .aspectRatio(1f) - .size(Dimens.Photo()) - .background( - color = MaterialTheme.colorScheme.surfaceVariant - ) - ) - } + } + if (bottomContent != null) { + bottomContent() } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/StickyHeader.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/MediaItemHeader.kt similarity index 98% rename from app/src/main/kotlin/com/dot/gallery/core/presentation/components/StickyHeader.kt rename to app/src/main/kotlin/com/dot/gallery/core/presentation/components/MediaItemHeader.kt index f3b8ba90b..82681647b 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/StickyHeader.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/MediaItemHeader.kt @@ -28,7 +28,7 @@ import com.dot.gallery.core.Constants.Animation.exitAnimation @OptIn(ExperimentalFoundationApi::class) @Composable -fun StickyHeader( +fun MediaItemHeader( modifier: Modifier = Modifier, date: String, showAsBig: Boolean = false, @@ -55,7 +55,7 @@ fun StickyHeader( verticalAlignment = Alignment.CenterVertically ) { Text( - text = date, + text = remember { date }, style = if (showAsBig) bigTextStyle else smallTextStyle, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.then( diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationBarSpacer.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationBarSpacer.kt new file mode 100644 index 000000000..3ed32a833 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationBarSpacer.kt @@ -0,0 +1,17 @@ +package com.dot.gallery.core.presentation.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.dot.gallery.feature_node.presentation.util.getNavigationBarHeight + +@Composable +fun NavigationBarSpacer() { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(getNavigationBarHeight()) + ) +} \ 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 4ed237710..f5e339581 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,7 +6,6 @@ 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 @@ -39,7 +38,6 @@ import com.dot.gallery.core.Constants.Target.TARGET_TRASH import com.dot.gallery.core.Settings.Album.rememberHideTimelineOnAlbum import com.dot.gallery.core.Settings.Misc.rememberLastScreen import com.dot.gallery.core.Settings.Misc.rememberTimelineGroupByMonth -import com.dot.gallery.core.presentation.components.util.ObserveCustomMediaState import com.dot.gallery.core.presentation.components.util.OnLifecycleEvent import com.dot.gallery.core.presentation.components.util.permissionGranted import com.dot.gallery.feature_node.presentation.albums.AlbumsScreen @@ -57,8 +55,7 @@ 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.vault.VaultScreen -import com.dot.gallery.feature_node.presentation.vault.VaultViewModel -import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.EncryptedMediaViewScreen +import kotlinx.coroutines.Dispatchers @Stable @NonRestartableComposable @@ -83,12 +80,14 @@ fun NavigationComp( val groupTimelineByMonth by rememberTimelineGroupByMonth() val context = LocalContext.current - var permissionState = remember { context.permissionGranted(Constants.PERMISSIONS) } + var permissionState = rememberSaveable { context.permissionGranted(Constants.PERMISSIONS) } var lastStartScreen by rememberLastScreen() - val startDest = remember(permissionState, lastStartScreen) { - if (permissionState) { - lastStartScreen - } else Screen.SetupScreen() + val startDest by rememberSaveable(permissionState, lastStartScreen) { + mutableStateOf( + if (permissionState) { + lastStartScreen + } else Screen.SetupScreen() + ) } val currentDest = remember(navController.currentDestination) { navController.currentDestination?.route ?: lastStartScreen @@ -119,22 +118,21 @@ fun NavigationComp( if (it != Screen.VaultScreen()) { shouldSkipAuth.value = false } - if (it.contains(Screen.EncryptedMediaViewScreen())) { - shouldSkipAuth.value = true - } systemBarFollowThemeState.value = !it.contains(Screen.MediaViewScreen.route) } } // Preloaded viewModels - val albumsViewModel = hiltViewModel().apply { - attachToLifecycle() - } + val albumsViewModel = hiltViewModel() + val albumsState = + albumsViewModel.albumsFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) + + val timelineViewModel = hiltViewModel() + timelineViewModel.CollectDatabaseUpdates() + + val timelineState = + timelineViewModel.mediaFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) - val timelineViewModel = hiltViewModel().apply { - attachToLifecycle() - } - val vaults by timelineViewModel.vaults.collectAsStateWithLifecycle() LaunchedEffect(groupTimelineByMonth) { timelineViewModel.groupByMonth = groupTimelineByMonth } @@ -162,11 +160,10 @@ fun NavigationComp( route = Screen.TimelineScreen() ) { TimelineScreen( - vm = timelineViewModel, paddingValues = paddingValues, handler = timelineViewModel.handler, - mediaState = timelineViewModel.mediaState, - albumState = albumsViewModel.albumsState, + mediaState = timelineState, + albumsState = albumsState, selectionState = timelineViewModel.multiSelectState, selectedMedia = timelineViewModel.selectedPhotoState, toggleSelection = timelineViewModel::toggleSelection, @@ -180,19 +177,19 @@ fun NavigationComp( composable( route = Screen.TrashedScreen() ) { - val viewModel = hiltViewModel() - .apply { target = TARGET_TRASH } - .apply { groupByMonth = groupTimelineByMonth } - viewModel.attachToLifecycle() + val vm = hiltViewModel().apply { + target = TARGET_TRASH + } + val trashedMediaState = + vm.mediaFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) TrashedGridScreen( - vm = viewModel, paddingValues = paddingValues, - mediaState = viewModel.mediaState, - albumState = albumsViewModel.albumsState, - selectionState = viewModel.multiSelectState, - selectedMedia = viewModel.selectedPhotoState, - handler = viewModel.handler, - toggleSelection = viewModel::toggleSelection, + handler = vm.handler, + mediaState = trashedMediaState, + albumsState = albumsState, + selectionState = vm.multiSelectState, + selectedMedia = vm.selectedPhotoState, + toggleSelection = vm::toggleSelection, navigate = navPipe::navigate, navigateUp = navPipe::navigateUp, toggleNavbar = navPipe::toggleNavbar @@ -201,17 +198,20 @@ fun NavigationComp( composable( route = Screen.FavoriteScreen() ) { - timelineViewModel.ObserveCustomMediaState(MediaViewModel::getFavoriteMedia) + val vm = hiltViewModel().apply { + target = TARGET_FAVORITES + } + val favoritesMediaState = + vm.mediaFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) FavoriteScreen( - vm = timelineViewModel, paddingValues = paddingValues, - mediaState = timelineViewModel.customMediaState, - albumState = albumsViewModel.albumsState, - handler = timelineViewModel.handler, - selectionState = timelineViewModel.multiSelectState, - selectedMedia = timelineViewModel.selectedPhotoState, - toggleFavorite = timelineViewModel::toggleFavorite, - toggleSelection = timelineViewModel::toggleCustomSelection, + handler = vm.handler, + mediaState = favoritesMediaState, + albumsState = albumsState, + selectionState = vm.multiSelectState, + selectedMedia = vm.selectedPhotoState, + toggleFavorite = vm::toggleFavorite, + toggleSelection = vm::toggleSelection, navigate = navPipe::navigate, navigateUp = navPipe::navigateUp, toggleNavbar = navPipe::toggleNavbar @@ -221,13 +221,17 @@ fun NavigationComp( route = Screen.AlbumsScreen() ) { AlbumsScreen( - mediaViewModel = timelineViewModel, navigate = navPipe::navigate, toggleNavbar = navPipe::toggleNavbar, + mediaState = timelineState, + albumsState = albumsState, paddingValues = paddingValues, - viewModel = albumsViewModel, isScrolling = isScrolling, - searchBarActive = searchBarActive + searchBarActive = searchBarActive, + onAlbumClick = albumsViewModel.onAlbumClick(navPipe::navigate), + onAlbumLongClick = albumsViewModel.onAlbumLongClick, + filterOptions = albumsViewModel.rememberFilters(), + onMoveAlbumToTrash = albumsViewModel::moveAlbumToTrash ) } composable( @@ -250,24 +254,24 @@ fun NavigationComp( val argumentAlbumId = remember(backStackEntry) { backStackEntry.arguments?.getLong("albumId") ?: -1 } - timelineViewModel.ObserveCustomMediaState { - getMediaFromAlbum(argumentAlbumId) + val vm = hiltViewModel().apply { + albumId = argumentAlbumId } val hideTimeline by rememberHideTimelineOnAlbum() + val mediaState = vm.mediaFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) TimelineScreen( - vm = timelineViewModel, paddingValues = paddingValues, albumId = argumentAlbumId, albumName = argumentAlbumName, - handler = timelineViewModel.handler, - mediaState = timelineViewModel.customMediaState, - albumState = albumsViewModel.albumsState, - selectionState = timelineViewModel.multiSelectState, - selectedMedia = timelineViewModel.selectedPhotoState, + handler = vm.handler, + mediaState = mediaState, + albumsState = albumsState, + selectionState = vm.multiSelectState, + selectedMedia = vm.selectedPhotoState, allowNavBar = false, allowHeaders = !hideTimeline, enableStickyHeaders = !hideTimeline, - toggleSelection = timelineViewModel::toggleCustomSelection, + toggleSelection = vm::toggleSelection, navigate = navPipe::navigate, navigateUp = navPipe::navigateUp, toggleNavbar = navPipe::toggleNavbar, @@ -279,11 +283,11 @@ fun NavigationComp( arguments = listOf( navArgument(name = "mediaId") { type = NavType.LongType - defaultValue = -1 + defaultValue = -1L }, navArgument(name = "albumId") { type = NavType.LongType - defaultValue = -1 + defaultValue = -1L } ) ) { backStackEntry -> @@ -293,15 +297,25 @@ fun NavigationComp( val albumId: Long = remember(backStackEntry) { backStackEntry.arguments?.getLong("albumId") ?: -1L } - AnimatedVisibility(albumId != -1L) { - timelineViewModel.ObserveCustomMediaState { - getMediaFromAlbum(albumId) + val entryName = remember(backStackEntry) { + if (albumId == -1L) { + Screen.TimelineScreen.route + } else { + Screen.AlbumViewScreen.route } } - val mediaState by remember(albumId) { - if (albumId != -1L) timelineViewModel.customMediaState else timelineViewModel.mediaState - }.collectAsStateWithLifecycle() - val albumsState by albumsViewModel.albumsState.collectAsStateWithLifecycle() + + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry(entryName) + } + val vm = hiltViewModel(parentEntry).apply { + this.albumId = albumId + } + val mediaState = if (entryName == Screen.AlbumViewScreen()) { + vm.mediaFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) + } else timelineState + + val vaultState = timelineViewModel.vaultsFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) MediaViewScreen( navigateUp = navPipe::navigateUp, toggleRotate = toggleRotate, @@ -309,9 +323,9 @@ fun NavigationComp( mediaId = mediaId, mediaState = mediaState, albumsState = albumsState, - handler = timelineViewModel.handler, - addMedia = timelineViewModel::addMedia, - vaults = vaults + handler = vm.handler, + addMedia = vm::addMedia, + vaultState = vaultState ) } composable( @@ -343,20 +357,13 @@ fun NavigationComp( val parentEntry = remember(backStackEntry) { navController.getBackStackEntry(entryName) } - val viewModel = if (target == TARGET_FAVORITES) { - timelineViewModel.apply { - ObserveCustomMediaState(MediaViewModel::getFavoriteMedia) - } - } else { - hiltViewModel(parentEntry).apply { - attachToLifecycle() - } + val viewModel = hiltViewModel(parentEntry).apply { + this.target = target } - val mediaState by remember(target) { - if (target == TARGET_FAVORITES) viewModel.customMediaState else viewModel.mediaState - }.collectAsStateWithLifecycle() - val albumsState by albumsViewModel.albumsState.collectAsStateWithLifecycle() + val mediaState = + viewModel.mediaFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) + val vaultState = timelineViewModel.vaultsFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) MediaViewScreen( navigateUp = navPipe::navigateUp, toggleRotate = toggleRotate, @@ -367,7 +374,48 @@ fun NavigationComp( albumsState = albumsState, handler = viewModel.handler, addMedia = viewModel::addMedia, - vaults = vaults + vaultState = vaultState + ) + } + composable( + route = Screen.MediaViewScreen.idAndQuery(), + arguments = listOf( + navArgument(name = "mediaId") { + type = NavType.LongType + defaultValue = -1 + }, + navArgument(name = "query") { + type = NavType.BoolType + defaultValue = true + } + ) + ) { backStackEntry -> + val mediaId: Long = remember(backStackEntry) { + backStackEntry.arguments?.getLong("mediaId") ?: -1 + } + val query: Boolean = remember(backStackEntry) { + backStackEntry.arguments?.getBoolean("query") ?: true + } + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry(Screen.TimelineScreen.route) + } + val viewModel = hiltViewModel(parentEntry) + val mediaState = remember(query) { + if (query) viewModel.searchMediaState else viewModel.mediaFlow + }.collectAsStateWithLifecycle(context = Dispatchers.IO) + + val vaultState = timelineViewModel.vaultsFlow.collectAsStateWithLifecycle(context = Dispatchers.IO) + + MediaViewScreen( + navigateUp = navPipe::navigateUp, + toggleRotate = toggleRotate, + paddingValues = paddingValues, + mediaId = mediaId, + mediaState = mediaState, + albumsState = albumsState, + handler = viewModel.handler, + addMedia = viewModel::addMedia, + vaultState = vaultState ) } composable( @@ -381,7 +429,6 @@ fun NavigationComp( composable( route = Screen.IgnoredScreen() ) { - val albumsState by albumsViewModel.unfilteredAlbums.collectAsStateWithLifecycle() IgnoredScreen( navigateUp = navPipe::navigateUp, startSetup = { navPipe.navigate(Screen.IgnoredSetupScreen()) }, @@ -392,7 +439,6 @@ fun NavigationComp( composable( route = Screen.IgnoredSetupScreen() ) { - val albumsState by albumsViewModel.unfilteredAlbums.collectAsStateWithLifecycle() IgnoredSetup( onCancel = navPipe::navigateUp, albumState = albumsState @@ -403,39 +449,11 @@ fun NavigationComp( composable( route = Screen.VaultScreen() ) { - val vm = hiltViewModel() - VaultScreen( - shouldSkipAuth = shouldSkipAuth, - navigateUp = navPipe::navigateUp, - navigate = navPipe::navigate, - vm = vm - ) - } - composable( - route = Screen.EncryptedMediaViewScreen.id(), - arguments = listOf( - navArgument("mediaId") { - type = NavType.LongType - } - ) - ) { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry(Screen.VaultScreen()) - } - val mediaId = remember(backStackEntry) { - backStackEntry.arguments?.getLong("mediaId") - } - val vm = hiltViewModel(parentEntry) - EncryptedMediaViewScreen( - navigateUp = navPipe::navigateUp, - toggleRotate = toggleRotate, paddingValues = paddingValues, - mediaId = mediaId ?: -1, - mediaState = vm.mediaState, - vault = vm.currentVault, - restoreMedia = vm::restoreMedia, - deleteMedia = vm::deleteMedia + toggleRotate = toggleRotate, + shouldSkipAuth = shouldSkipAuth, + navigateUp = navPipe::navigateUp ) } } @@ -445,7 +463,6 @@ fun NavigationComp( ) { LibraryScreen( navigate = navPipe::navigate, - mediaViewModel = timelineViewModel, toggleNavbar = navPipe::toggleNavbar, paddingValues = paddingValues, isScrolling = isScrolling, diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt index c9c9db1b7..6e5e77fae 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/SelectionSheet.kt @@ -6,8 +6,6 @@ package com.dot.gallery.core.presentation.components import android.app.Activity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -47,8 +45,10 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable 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.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -65,14 +65,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.dot.gallery.R -import com.dot.gallery.core.AlbumState -import com.dot.gallery.core.Constants.Target.TARGET_FAVORITES +import com.dot.gallery.feature_node.domain.model.AlbumState 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.exif.CopyMediaSheet import com.dot.gallery.feature_node.presentation.exif.MoveMediaSheet import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialog import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction +import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.shareMedia import com.dot.gallery.ui.theme.Shapes @@ -85,7 +85,7 @@ fun SelectionSheet( target: String?, selectedMedia: SnapshotStateList, selectionState: MutableState, - albumsState: AlbumState, + albumsState: State, handler: MediaHandleUseCase ) { fun clearSelection() { @@ -99,24 +99,25 @@ fun SelectionSheet( val trashSheetState = rememberAppBottomSheetState() val moveSheetState = rememberAppBottomSheetState() val copySheetState = rememberAppBottomSheetState() - val result = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartIntentSenderForResult(), - onResult = { - if (it.resultCode == Activity.RESULT_OK) { - clearSelection() - if (trashSheetState.isVisible) { - scope.launch { - trashSheetState.hide() - shouldMoveToTrash = true - } + val result = rememberActivityResult( + onResultOk = { + clearSelection() + if (trashSheetState.isVisible) { + scope.launch { + trashSheetState.hide() + shouldMoveToTrash = true } } } ) val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity) - val tabletMode = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact - val sizeModifier = if (!tabletMode) Modifier.fillMaxWidth() - else Modifier.wrapContentWidth() + val tabletMode = remember(windowSizeClass) { + windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact + } + val sizeModifier = remember(tabletMode) { + if (!tabletMode) Modifier.fillMaxWidth() + else Modifier.wrapContentWidth() + } AnimatedVisibility( modifier = modifier, visible = selectionState.value, @@ -145,7 +146,7 @@ fun SelectionSheet( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { IconButton( - onClick = { clearSelection() }, + onClick = ::clearSelection, modifier = Modifier.size(24.dp), ) { Image( @@ -173,30 +174,24 @@ fun SelectionSheet( ) { // Share Component SelectionBarColumn( - selectedMedia = selectedMedia, imageVector = Icons.Outlined.Share, tabletMode = tabletMode, title = stringResource(R.string.share) ) { - context.shareMedia(it) + context.shareMedia(selectedMedia) } - val favoriteTitle = - if (target == TARGET_FAVORITES) stringResource(id = R.string.remove_selected) - else stringResource(id = R.string.favorite) // Favorite Component SelectionBarColumn( - selectedMedia = selectedMedia, imageVector = Icons.Outlined.FavoriteBorder, tabletMode = tabletMode, - title = favoriteTitle + title = stringResource(R.string.favorite) ) { scope.launch { - handler.toggleFavorite(result = result, it) + handler.toggleFavorite(result = result, selectedMedia) } } // Copy Component SelectionBarColumn( - selectedMedia = selectedMedia, imageVector = Icons.Outlined.CopyAll, tabletMode = tabletMode, title = stringResource(R.string.copy) @@ -207,7 +202,6 @@ fun SelectionSheet( } // Move Component SelectionBarColumn( - selectedMedia = selectedMedia, imageVector = Icons.AutoMirrored.Outlined.DriveFileMove, tabletMode = tabletMode, title = stringResource(R.string.move) @@ -218,7 +212,6 @@ fun SelectionSheet( } // Trash Component SelectionBarColumn( - selectedMedia = selectedMedia, imageVector = Icons.Outlined.DeleteOutline, tabletMode = tabletMode, title = stringResource(id = R.string.trash), @@ -242,7 +235,7 @@ fun SelectionSheet( MoveMediaSheet( sheetState = moveSheetState, mediaList = selectedMedia, - albumState = albumsState, + albumState = albumsState.value, handler = handler, onFinish = ::clearSelection ) @@ -250,7 +243,7 @@ fun SelectionSheet( CopyMediaSheet( sheetState = copySheetState, mediaList = selectedMedia, - albumsState = albumsState, + albumsState = albumsState.value, handler = handler, onFinish = ::clearSelection ) @@ -258,7 +251,9 @@ fun SelectionSheet( TrashDialog( appBottomSheetState = trashSheetState, data = selectedMedia, - action = if (shouldMoveToTrash) TrashDialogAction.TRASH else TrashDialogAction.DELETE, + action = remember(shouldMoveToTrash) { + if (shouldMoveToTrash) TrashDialogAction.TRASH else TrashDialogAction.DELETE + }, ) { if (shouldMoveToTrash) { handler.trashMedia(result, it, true) @@ -271,12 +266,11 @@ fun SelectionSheet( @OptIn(ExperimentalFoundationApi::class) @Composable private fun RowScope.SelectionBarColumn( - selectedMedia: SnapshotStateList, imageVector: ImageVector, title: String, tabletMode: Boolean, - onItemLongClick: ((List) -> Unit)? = null, - onItemClick: (List) -> Unit, + onItemLongClick: (() -> Unit)? = null, + onItemClick: () -> Unit, ) { val tintColor = MaterialTheme.colorScheme.onSurface Column( @@ -288,12 +282,8 @@ private fun RowScope.SelectionBarColumn( else Modifier.weight(1f) ) .combinedClickable( - onClick = { - onItemClick.invoke(selectedMedia) - }, - onLongClick = { - onItemLongClick?.invoke(selectedMedia) - } + onClick = onItemClick, + onLongClick = onItemLongClick ) .padding(top = 12.dp, bottom = 16.dp), verticalArrangement = Arrangement.Center, 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 index 1e7e1c737..2554cdf6a 100644 --- 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 @@ -27,6 +27,7 @@ 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 androidx.compose.ui.unit.dp import com.dot.gallery.ui.theme.GalleryTheme @@ -36,6 +37,7 @@ fun SetupWizard( icon: ImageVector, title: String, subtitle: String, + contentPadding: Dp = 32.dp, content: @Composable () -> Unit, bottomBar: @Composable () -> Unit ) { @@ -96,6 +98,8 @@ fun SetupWizard( modifier = Modifier .padding(horizontal = 32.dp) .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally ) { content() } @@ -109,6 +113,7 @@ fun SetupWizard( painter: Painter, title: String, subtitle: String, + contentPadding: Dp = 32.dp, content: @Composable () -> Unit, bottomBar: @Composable () -> Unit ) { @@ -169,6 +174,8 @@ fun SetupWizard( modifier = Modifier .padding(horizontal = 32.dp) .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(contentPadding), + horizontalAlignment = Alignment.CenterHorizontally ) { content() } diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/BatteryExt.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/BatteryExt.kt index 1195efe25..0594705b3 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/BatteryExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/BatteryExt.kt @@ -8,6 +8,7 @@ import android.os.PowerManager import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,6 +63,7 @@ class BatteryStatusReceiver(private val onReceive: () -> Unit) : BroadcastReceiv } +@Stable data class BatteryStatus( val isPowerSavingMode: Boolean ) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/observeCustomMediaState.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/observeCustomMediaState.kt deleted file mode 100644 index 260e4be4b..000000000 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/observeCustomMediaState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.dot.gallery.core.presentation.components.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.feature_node.presentation.common.MediaViewModel - -@Composable -fun MediaViewModel.ObserveCustomMediaState(onChange: MediaViewModel.() -> Unit) { - val state by mediaState.collectAsStateWithLifecycle() - LaunchedEffect(state) { - onChange() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/swipe.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/swipe.kt new file mode 100644 index 000000000..f29f31930 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/util/swipe.kt @@ -0,0 +1,69 @@ +package com.dot.gallery.core.presentation.components.util + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import kotlin.math.roundToInt + +@Composable +fun Modifier.swipe( + enabled: Boolean = true, + onSwipeDown: () -> Unit, + onSwipeUp: (() -> Unit)? +): Modifier { + var delta by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + val feedbackManager = rememberFeedbackManager() + var isVibrating by remember { mutableStateOf(false) } + val draggableState = rememberDraggableState { + delta += if (onSwipeUp != null) it else if (it > 0) it else 0f + delta = delta.coerceIn(-185f, 400f) + if (!isVibrating && (delta == 400f || (onSwipeUp != null && delta == -185f))) { + feedbackManager.vibrate() + isVibrating = true + } + } + val animatedDelta by animateFloatAsState( + label = "animatedDelta", + targetValue = if (isDragging) delta else 0f, + animationSpec = tween( + durationMillis = 200 + ) + ) + return this then Modifier + .draggable( + enabled = enabled, + state = draggableState, + orientation = Orientation.Vertical, + onDragStarted = { + isVibrating = false + isDragging = true + }, + onDragStopped = { + isVibrating = false + isDragging = false + if (delta == 400f) { + onSwipeDown() + } + if (onSwipeUp != null && delta == -185f) { + onSwipeUp() + } + delta = 0f + } + ) + .offset { + IntOffset(0, if (isDragging) delta.roundToInt() else animatedDelta.roundToInt()) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/Column.kt b/app/src/main/kotlin/com/dot/gallery/core/util/Column.kt new file mode 100644 index 000000000..966d6baa8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/Column.kt @@ -0,0 +1,34 @@ +package com.dot.gallery.core.util + +typealias Column = String + +sealed interface Node { + fun build(): String = when (this) { + is Eq -> "${lhs.build()} = ${rhs.build()}" + is Or -> "(${lhs.build()}) OR (${rhs.build()})" + is And -> "(${lhs.build()}) AND (${rhs.build()})" + is Literal<*> -> "$`val`" + } +} + +private class Eq(val lhs: Node, val rhs: Node) : Node +private class Or(val lhs: Node, val rhs: Node) : Node +private class And(val lhs: Node, val rhs: Node) : Node +private class Literal(val `val`: T) : Node + +class Query(val root: Node) { + fun build() = root.build() + + companion object { + const val ARG = "?" + } +} + +infix fun Query.or(other: Query) = Query(Or(this.root, other.root)) +infix fun Query.and(other: Query) = Query(And(this.root, other.root)) +infix fun Query.eq(other: Query) = Query(Eq(this.root, other.root)) +infix fun Column.eq(other: T) = Query(Literal(this)) eq Query(Literal(other)) + +fun Iterable.join( + func: Query.(other: Query) -> Query, +) = reduceOrNull(func) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/MediaStoreBuckets.kt b/app/src/main/kotlin/com/dot/gallery/core/util/MediaStoreBuckets.kt new file mode 100644 index 000000000..b385a2d54 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/MediaStoreBuckets.kt @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ +package com.dot.gallery.core.util + +enum class MediaStoreBuckets { + /** + * Favorites album. + */ + MEDIA_STORE_BUCKET_FAVORITES, + + /** + * Trash album. + */ + MEDIA_STORE_BUCKET_TRASH, + + /** + * Timeline, contains all medias. + */ + MEDIA_STORE_BUCKET_TIMELINE, + + /** + * Reserved bucket ID for placeholders, throw an exception if this value is used. + */ + MEDIA_STORE_BUCKET_PLACEHOLDER, + + /** + * Timeline, contains only photos. + */ + MEDIA_STORE_BUCKET_PHOTOS, + + /** + * Timeline, contains only videos. + */ + MEDIA_STORE_BUCKET_VIDEOS; + + val id = (-0x0000DEAD - ((ordinal + 1) shl 16)).toLong() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt b/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt new file mode 100644 index 000000000..611e24281 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/PickerUtils.kt @@ -0,0 +1,58 @@ +package com.dot.gallery.core.util + +import android.content.Intent +import android.provider.MediaStore +import com.dot.gallery.feature_node.domain.model.MediaType + +object PickerUtils { + private const val MIME_TYPE_IMAGE_ANY = "image/*" + private const val MIME_TYPE_VIDEO_ANY = "video/*" + private const val MIME_TYPE_ANY = "*/*" + + /** + * Get a fixed up MIME type from an [Intent]. + * @param intent An [Intent] + * @return A simpler MIME type, null if not supported + */ + fun translateMimeType(intent: Intent?) = when (intent?.action) { + Intent.ACTION_SET_WALLPAPER -> MIME_TYPE_IMAGE_ANY + else -> (intent?.type ?: MIME_TYPE_ANY).let { + when (it) { + MediaStore.Images.Media.CONTENT_TYPE -> MIME_TYPE_IMAGE_ANY + MediaStore.Video.Media.CONTENT_TYPE -> MIME_TYPE_VIDEO_ANY + else -> when { + it == MIME_TYPE_ANY + || it.startsWith("image/") + || it.startsWith("video/") -> it + + else -> null + } + } + } + } + + /** + * Get a [MediaType] only if the provided MIME type is a generic one, else return null. + * @param mimeType A MIME type + * @return [MediaType] if the MIME type is generic, else null + * (assume MIME type represent either a specific file format or any) + */ + fun mediaTypeFromGenericMimeType(mimeType: String?) = when (mimeType) { + MIME_TYPE_IMAGE_ANY -> MediaType.IMAGE + MIME_TYPE_VIDEO_ANY -> MediaType.VIDEO + else -> null + } + + /** + * Given a MIME type, check if it specifies both a content type and a sub type. + * @param mimeType A MIME type + * @return true if it specifies both a file category and a specific type + */ + fun isMimeTypeNotGeneric(mimeType: String?) = mimeType?.let { + it !in listOf( + MIME_TYPE_IMAGE_ANY, + MIME_TYPE_VIDEO_ANY, + MIME_TYPE_ANY, + ) + } ?: false +} \ No newline at end of file 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/core/util/ext/ContentResolverExt.kt similarity index 65% rename from app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt rename to app/src/main/kotlin/com/dot/gallery/core/util/ext/ContentResolverExt.kt index 209abfd00..493d63a3a 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/util/ext/ContentResolverExt.kt @@ -1,57 +1,86 @@ /* - * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-FileCopyrightText: 2023 The LineageOS Project * SPDX-License-Identifier: Apache-2.0 */ -package com.dot.gallery.feature_node.data.data_types +package com.dot.gallery.core.util.ext import android.content.ContentResolver -import android.content.ContentUris import android.content.ContentValues -import android.database.Cursor -import android.database.MergeCursor +import android.database.ContentObserver import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal import android.os.Environment import android.os.FileUtils +import android.os.Handler +import android.os.Looper import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.dot.gallery.core.Constants -import com.dot.gallery.feature_node.data.data_source.Query import com.dot.gallery.feature_node.domain.model.ExifAttributes import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.presentation.util.getDate +import com.dot.gallery.feature_node.domain.model.isVideo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.IOException +fun ContentResolver.queryFlow( + uri: Uri, + projection: Array? = null, + queryArgs: Bundle? = Bundle(), +) = callbackFlow { + // Each query will have its own cancellationSignal. + // Before running any new query the old cancellationSignal must be cancelled + // to ensure the currently running query gets interrupted so that we don't + // send data across the channel if we know we received a newer set of data. + var cancellationSignal = CancellationSignal() + // ContentObserver.onChange can be called concurrently so make sure + // access to the cancellationSignal is synchronized. + val mutex = Mutex() + + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + launch(Dispatchers.IO) { + mutex.withLock { + cancellationSignal.cancel() + cancellationSignal = CancellationSignal() + } + runCatching { + trySend(query(uri, projection, queryArgs, cancellationSignal)) + } + } + } + } + + registerContentObserver(uri, true, observer) -suspend fun ContentResolver.query( - mediaQuery: Query -): Cursor { - return withContext(Dispatchers.IO) { - return@withContext MergeCursor( - arrayOf( - query( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - mediaQuery.projection, - mediaQuery.bundle, - null - ), - query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - mediaQuery.projection, - mediaQuery.bundle, - null - ) + // The first set of values must always be generated and cannot (shouldn't) be cancelled. + launch(Dispatchers.IO) { + runCatching { + trySend( + query(uri, projection, queryArgs, null) ) - ) + } } -} + + awaitClose { + // Stop receiving content changes. + unregisterContentObserver(observer) + // Cancel any possibly running query. + cancellationSignal.cancel() + } +}.conflate() suspend fun ContentResolver.copyMedia( from: Media, @@ -104,7 +133,6 @@ suspend fun ContentResolver.copyMedia( } } - fun ContentResolver.overrideImage( uri: Uri, bitmap: Bitmap, @@ -223,72 +251,3 @@ suspend fun ContentResolver.updateMediaExif( false } } - - -@Throws(Exception::class) -fun Cursor.getMediaFromCursor(): Media { - val id: Long = - getLong(getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - val path: String = - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) - val relativePath: String = - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH)) - val title: String = - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val albumID: Long = - getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)) - val albumLabel: String? = try { - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME)) - } catch (_: Exception) { - Build.MODEL - } - val takenTimestamp: Long? = try { - getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)) - } catch (_: Exception) { - null - } - val modifiedTimestamp: Long = - getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)) - val duration: String? = try { - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)) - } catch (_: Exception) { - null - } - val size: Long = - getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)) - val mimeType: String = - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)) - val isFavorite: Int = - getInt(getColumnIndexOrThrow(MediaStore.MediaColumns.IS_FAVORITE)) - val isTrashed: Int = - getInt(getColumnIndexOrThrow(MediaStore.MediaColumns.IS_TRASHED)) - val expiryTimestamp: Long? = try { - getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_EXPIRES)) - } catch (_: Exception) { - null - } - val contentUri = if (mimeType.contains("image")) - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - else - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - val uri = ContentUris.withAppendedId(contentUri, id) - val formattedDate = modifiedTimestamp.getDate(Constants.FULL_DATE_FORMAT) - return Media( - id = id, - label = title, - uri = uri, - path = path, - relativePath = relativePath, - albumID = albumID, - albumLabel = albumLabel ?: Build.MODEL, - timestamp = modifiedTimestamp, - takenTimestamp = takenTimestamp, - expiryTimestamp = expiryTimestamp, - fullDate = formattedDate, - 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/core/util/ext/CursorExt.kt b/app/src/main/kotlin/com/dot/gallery/core/util/ext/CursorExt.kt new file mode 100644 index 000000000..39a2fb352 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/ext/CursorExt.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.util.ext + +import android.database.Cursor +import androidx.core.database.getLongOrNull +import androidx.core.database.getStringOrNull +import com.dot.gallery.core.Resource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +fun Cursor?.mapEachRow( + projection: Array, + mapping: (Cursor, Array) -> T, +) = this?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use emptyList() + } + + val indexCache = projection.map { column -> + cursor.getColumnIndexOrThrow(column) + }.toTypedArray() + + val data = mutableListOf() + do { + data.add(mapping(cursor, indexCache)) + } while (cursor.moveToNext()) + + data.toList() +} ?: emptyList() + +fun Cursor?.tryGetString(columnIndex: Int, fallback: String? = null): String? { + return this?.getStringOrNull(columnIndex) ?: fallback +} + +fun Cursor?.tryGetLong(columnIndex: Int, fallback: Long? = null): Long? { + return this?.getLongOrNull(columnIndex) ?: fallback +} + +fun Flow>.mapAsResource(errorOnEmpty: Boolean = false, errorMessage: String = "No data found") = map { + if (errorOnEmpty && it.isEmpty()) { + Resource.Error(errorMessage) + } else { + Resource.Success(it) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/ext/ExifInterfaceExt.kt b/app/src/main/kotlin/com/dot/gallery/core/util/ext/ExifInterfaceExt.kt new file mode 100644 index 000000000..122b7c543 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/ext/ExifInterfaceExt.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.util.ext + +import android.util.Size +import androidx.exifinterface.media.ExifInterface + +private const val DEFAULT_VALUE_INT = -1 +private const val DEFAULT_VALUE_DOUBLE = -1.0 + +fun ExifInterface.getAttributeInt(tag: String) = + getAttributeInt(tag, DEFAULT_VALUE_INT).takeIf { + it != DEFAULT_VALUE_INT + } + +fun ExifInterface.getAttributeDouble(tag: String): Double? = + getAttributeDouble(tag, DEFAULT_VALUE_DOUBLE).takeIf { + it != DEFAULT_VALUE_DOUBLE + } + +val ExifInterface.artist + get() = getAttribute(ExifInterface.TAG_ARTIST) + +val ExifInterface.apertureValue + get() = getAttributeDouble(ExifInterface.TAG_APERTURE_VALUE) + +val ExifInterface.copyright + get() = getAttribute(ExifInterface.TAG_COPYRIGHT) + +val ExifInterface.exposureTime + get() = getAttributeDouble(ExifInterface.TAG_EXPOSURE_TIME) + +val ExifInterface.isoSpeed + get() = getAttributeInt(ExifInterface.TAG_ISO_SPEED) + +val ExifInterface.focalLength + get() = getAttributeDouble(ExifInterface.TAG_FOCAL_LENGTH) + +val ExifInterface.make + get() = getAttribute(ExifInterface.TAG_MAKE) + +val ExifInterface.model + get() = getAttribute(ExifInterface.TAG_MODEL) + +val ExifInterface.pixelXDimension + get() = getAttributeInt(ExifInterface.TAG_PIXEL_X_DIMENSION) + +val ExifInterface.pixelYDimension + get() = getAttributeInt(ExifInterface.TAG_PIXEL_Y_DIMENSION) + +val ExifInterface.size + get() = pixelXDimension?.let { x -> pixelYDimension?.let { y -> Size(x, y) } } + +val ExifInterface.software + get() = getAttribute(ExifInterface.TAG_SOFTWARE) + +var ExifInterface.userComment + get() = getAttribute(ExifInterface.TAG_USER_COMMENT) + set(value) { + setAttribute(ExifInterface.TAG_USER_COMMENT, value) + } + +val ExifInterface.isSupportedFormatForSavingAttributes: Boolean + get() { + val mimeType = ExifInterface::class.java.getDeclaredField("mMimeType").apply { + isAccessible = true + }.get(this) as Int + + val isSupportedFormatForSavingAttributes = ExifInterface::class.java.getDeclaredMethod( + "isSupportedFormatForSavingAttributes", Int::class.java + ).apply { + isAccessible = true + } + + return isSupportedFormatForSavingAttributes.invoke(null, mimeType) as Boolean + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/util/ext/FlowExt.kt b/app/src/main/kotlin/com/dot/gallery/core/util/ext/FlowExt.kt new file mode 100644 index 000000000..a0176af1f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/core/util/ext/FlowExt.kt @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.core.util.ext + +import android.database.Cursor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +fun Flow.mapEachRow( + projection: Array, + mapping: (Cursor, Array) -> T, +) = map { it.mapEachRow(projection, mapping) } \ No newline at end of file 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 119ccf2aa..e18a30c8e 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 @@ -13,9 +13,6 @@ interface BlacklistDao { @Query("SELECT * FROM blacklist") fun getBlacklistedAlbums(): Flow> - @Query("SELECT * FROM blacklist") - fun getBlacklistedAlbumsSync(): List - @Upsert suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) 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 38a68b45e..4de3d2c3a 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 @@ -10,17 +10,21 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters 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.MediaVersion import com.dot.gallery.feature_node.domain.model.PinnedAlbum +import com.dot.gallery.feature_node.domain.model.TimelineSettings import com.dot.gallery.feature_node.domain.util.Converters @Database( - entities = [PinnedAlbum::class, IgnoredAlbum::class], - version = 4, + entities = [PinnedAlbum::class, IgnoredAlbum::class, Media::class, MediaVersion::class, TimelineSettings::class], + version = 5, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), - AutoMigration(from = 3, to = 4) + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), ] ) @TypeConverters(Converters::class) @@ -30,6 +34,8 @@ abstract class InternalDatabase: RoomDatabase() { abstract fun getBlacklistDao(): BlacklistDao + abstract fun getMediaDao(): MediaDao + companion object { const val NAME = "internal_db" } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt index 670f75b15..50aa5e897 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt @@ -32,23 +32,41 @@ class KeychainHolder @Inject constructor( .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - fun writeVaultInfo(vault: Vault, onSuccess: () -> Unit = {}) { - val vaultFolder = File(filesDir, vault.uuid.toString()) - if (!vaultFolder.exists()) { - vaultFolder.mkdir() + fun writeVaultInfo(vault: Vault, onSuccess: () -> Unit = {}, onFailed: (reason: String) -> Unit = {}) { + try { + val vaultFolder = File(filesDir, vault.uuid.toString()) + if (!vaultFolder.exists()) { + vaultFolder.mkdirs() + } + + File(vaultFolder, VAULT_INFO_FILE_NAME).apply { + if (exists()) delete() + encrypt(vault) + onSuccess() + } + } catch (e: Exception) { + e.printStackTrace() + onFailed(e.message.toString()) } + } - File(vaultFolder, VAULT_INFO_FILE_NAME).apply { - if (exists()) delete() - encrypt(vault) + fun deleteVault(vault: Vault, onSuccess: () -> Unit, onFailed: (reason: String) -> Unit) { + try { + val vaultFolder = vaultFolder(vault) + if (vaultFolder.exists()) { + vaultFolder.deleteRecursively() + } + onSuccess() + } catch (e: Exception) { + e.printStackTrace() + onFailed(e.message.toString()) } - onSuccess() } fun checkVaultFolder(vault: Vault) { val mainFolder = File(filesDir, vault.uuid.toString()) if (!mainFolder.exists()) { - mainFolder.mkdir() + mainFolder.mkdirs() writeVaultInfo(vault) } } @@ -63,7 +81,6 @@ class KeychainHolder @Inject constructor( fromByteArray(it.readBytes()) } - @Throws(GeneralSecurityException::class, IOException::class, FileNotFoundException::class, UserNotAuthenticatedException::class) fun File.encrypt(data: T) { EncryptedFile.Builder( diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt new file mode 100644 index 000000000..e09ccc978 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaDao.kt @@ -0,0 +1,67 @@ +package com.dot.gallery.feature_node.data.data_source + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaVersion +import com.dot.gallery.feature_node.domain.model.TimelineSettings +import com.dot.gallery.feature_node.presentation.picker.AllowedMedia +import kotlinx.coroutines.flow.Flow + +@Dao +interface MediaDao { + + /** Media */ + @Query("SELECT * FROM media ORDER BY timestamp DESC") + suspend fun getMedia(): List + + @Query("SELECT * FROM media WHERE mimeType LIKE :allowedMedia ORDER BY timestamp DESC") + suspend fun getMediaByType(allowedMedia: AllowedMedia): List + + @Query("SELECT * FROM media WHERE favorite = 1 ORDER BY timestamp DESC") + suspend fun getFavorites(): List + + @Query("SELECT * FROM media WHERE id = :id LIMIT 1") + suspend fun getMediaById(id: Long): Media + + @Query("SELECT * FROM media WHERE albumID = :albumId ORDER BY timestamp DESC") + suspend fun getMediaByAlbumId(albumId: Long): List + + @Query("SELECT * FROM media WHERE albumID = :albumId AND mimeType LIKE :allowedMedia ORDER BY timestamp DESC") + suspend fun getMediaByAlbumIdAndType(albumId: Long, allowedMedia: AllowedMedia): List + + @Upsert(entity = Media::class) + suspend fun addMediaList(mediaList: List) + + @Transaction + suspend fun updateMedia(mediaList: List) { + // Upsert the items in mediaList + addMediaList(mediaList) + + // Get the IDs of the media items in mediaList + val mediaIds = mediaList.map { it.id } + + // Delete items from the database that are not in mediaList + deleteMediaNotInList(mediaIds) + } + + @Query("DELETE FROM media WHERE id NOT IN (:mediaIds)") + suspend fun deleteMediaNotInList(mediaIds: List) + + /** MediaVersion */ + @Upsert(entity = MediaVersion::class) + suspend fun setMediaVersion(version: MediaVersion) + + @Query("SELECT EXISTS(SELECT * FROM media_version WHERE version = :version) LIMIT 1") + suspend fun isMediaVersionUpToDate(version: String): Boolean + + /** Timeline Settings */ + @Query("SELECT * FROM timeline_settings LIMIT 1") + fun getTimelineSettings(): Flow + + @Upsert(entity = TimelineSettings::class) + suspend fun setTimelineSettings(settings: TimelineSettings) + +} \ No newline at end of file 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 deleted file mode 100644 index 96cde8be7..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/MediaQuery.kt +++ /dev/null @@ -1,156 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_source - -import android.content.ContentResolver -import android.os.Bundle -import android.provider.MediaStore - -sealed class Query( - var projection: Array, - var bundle: Bundle? = null -) { - class MediaQuery : Query( - projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.RELATIVE_PATH, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.BUCKET_ID, - MediaStore.MediaColumns.DATE_MODIFIED, - 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 - ), - ) - - class TrashQuery : Query( - projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.RELATIVE_PATH, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.BUCKET_ID, - MediaStore.MediaColumns.DATE_MODIFIED, - 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 - ), - bundle = Bundle().apply { - putStringArray( - ContentResolver.QUERY_ARG_SORT_COLUMNS, - arrayOf(MediaStore.MediaColumns.DATE_EXPIRES) - ) - putInt( - ContentResolver.QUERY_ARG_SQL_SORT_ORDER, - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) - putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) - } - ) - - class PhotoQuery: Query( - projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.RELATIVE_PATH, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.BUCKET_ID, - MediaStore.MediaColumns.DATE_MODIFIED, - 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 - ), - bundle = defaultBundle.apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.MIME_TYPE + " like ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf("image%") - ) - } - ) - - class VideoQuery: Query( - projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.RELATIVE_PATH, - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.BUCKET_ID, - 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, - MediaStore.MediaColumns.IS_TRASHED - ), - bundle = defaultBundle.apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.MIME_TYPE + " LIKE ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf("video%") - ) - } - ) - - class AlbumQuery : Query( - projection = arrayOf( - MediaStore.MediaColumns.BUCKET_ID, - MediaStore.MediaColumns.BUCKET_DISPLAY_NAME, - MediaStore.MediaColumns.DISPLAY_NAME, - 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 - ) - ) - - fun copy( - projection: Array = this.projection, - bundle: Bundle? = this.bundle, - ): Query { - this.projection = projection - this.bundle = bundle - return this - } - - companion object { - val defaultBundle = Bundle().apply { - putStringArray( - ContentResolver.QUERY_ARG_SORT_COLUMNS, - arrayOf(MediaStore.MediaColumns.DATE_MODIFIED) - ) - putInt( - ContentResolver.QUERY_ARG_SQL_SORT_ORDER, - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery.kt new file mode 100644 index 000000000..01921e9b1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/MediaQuery.kt @@ -0,0 +1,46 @@ +package com.dot.gallery.feature_node.data.data_source.mediastore + +import android.net.Uri +import android.provider.MediaStore +import com.dot.gallery.core.util.eq +import com.dot.gallery.core.util.or + +object MediaQuery { + val MediaStoreFileUri: Uri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val MediaProjection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DATA, + MediaStore.Files.FileColumns.RELATIVE_PATH, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.BUCKET_ID, + MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_TAKEN, + MediaStore.Files.FileColumns.DATE_MODIFIED, + MediaStore.Files.FileColumns.DURATION, + MediaStore.Files.FileColumns.SIZE, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.IS_FAVORITE, + MediaStore.Files.FileColumns.IS_TRASHED, + MediaStore.Files.FileColumns.DATE_EXPIRES + ) + val AlbumsProjection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DATA, + MediaStore.Files.FileColumns.RELATIVE_PATH, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.BUCKET_ID, + MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME, + MediaStore.Files.FileColumns.DATE_TAKEN, + MediaStore.Files.FileColumns.DATE_MODIFIED, + MediaStore.Files.FileColumns.SIZE, + MediaStore.Files.FileColumns.MIME_TYPE, + ) + + object Selection { + val image = + MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + val video = + MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO + val imageOrVideo = image or video + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/AlbumsFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/AlbumsFlow.kt new file mode 100644 index 000000000..a5fa7e18a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/AlbumsFlow.kt @@ -0,0 +1,134 @@ +package com.dot.gallery.feature_node.data.data_source.mediastore.quries + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import androidx.core.os.bundleOf +import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery +import com.dot.gallery.core.util.PickerUtils +import com.dot.gallery.core.util.Query +import com.dot.gallery.core.util.and +import com.dot.gallery.core.util.eq +import com.dot.gallery.core.util.join +import com.dot.gallery.core.util.ext.queryFlow +import com.dot.gallery.core.util.ext.tryGetString +import com.dot.gallery.feature_node.domain.model.Album +import com.dot.gallery.feature_node.domain.model.MediaType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Albums flow + * + * This class is responsible for fetching albums from the media store + * + * @property context + * @property mimeType + */ +class AlbumsFlow( + private val context: Context, + private val mimeType: String? = null, +) : QueryFlow() { + override fun flowCursor(): Flow { + val uri = MediaQuery.MediaStoreFileUri + val projection = MediaQuery.AlbumsProjection + val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let { + when (it) { + MediaType.IMAGE -> MediaQuery.Selection.image + MediaType.VIDEO -> MediaQuery.Selection.video + } + } ?: MediaQuery.Selection.imageOrVideo + val rawMimeType = mimeType?.takeIf { PickerUtils.isMimeTypeNotGeneric(it) } + val mimeTypeQuery = rawMimeType?.let { + MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG + } + + // Join all the non-null queries + val selection = listOfNotNull( + mimeTypeQuery, + imageOrVideo, + ).join(Query::and) + + val selectionArgs = listOfNotNull( + rawMimeType, + ).toTypedArray() + + val sortOrder = MediaStore.Files.FileColumns.DATE_MODIFIED + " DESC" + + val queryArgs = Bundle().apply { + putAll( + bundleOf( + ContentResolver.QUERY_ARG_SQL_SELECTION to selection?.build(), + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs, + ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder, + ) + ) + } + + return context.contentResolver.queryFlow( + uri, + projection, + queryArgs, + ) + } + + override fun flowData() = flowCursor().map { + mutableMapOf().apply { + it?.use { + val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID) + val albumIdIndex = it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID) + val labelIndex = it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME) + val thumbnailPathIndex = it.getColumnIndex(MediaStore.Files.FileColumns.DATA) + val thumbnailRelativePathIndex = + it.getColumnIndex(MediaStore.Files.FileColumns.RELATIVE_PATH) + val thumbnailDateIndex = it.getColumnIndex(MediaStore.Files.FileColumns.DATE_MODIFIED) + val sizeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.SIZE) + val mimeTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE) + + if (!it.moveToFirst()) { + return@use + } + + while (!it.isAfterLast) { + val bucketId = it.getInt(albumIdIndex) + + this[bucketId]?.also { album -> + album.count += 1 + album.size += it.getLong(sizeIndex) + } ?: run { + val albumId = it.getLong(albumIdIndex) + val id = it.getLong(idIndex) + val label = it.tryGetString(labelIndex, Build.MODEL) + val thumbnailPath = it.getString(thumbnailPathIndex) + val thumbnailRelativePath = it.getString(thumbnailRelativePathIndex) + val thumbnailDate = it.getLong(thumbnailDateIndex) + val size = it.getLong(sizeIndex) + val mimeType = it.getString(mimeTypeIndex) + val contentUri = if (mimeType.contains("image")) + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + else + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + + this[bucketId] = Album( + id = albumId, + label = label ?: Build.MODEL, + uri = ContentUris.withAppendedId(contentUri, id), + pathToThumbnail = thumbnailPath, + relativePath = thumbnailRelativePath, + timestamp = thumbnailDate + ).apply { + this.count += 1 + this.size += size + } + } + + it.moveToNext() + } + } + }.values.toList() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/MediaFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/MediaFlow.kt new file mode 100644 index 000000000..2d632b8cb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/MediaFlow.kt @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.data.data_source.mediastore.quries + +import android.content.ContentResolver +import android.content.ContentUris +import android.database.Cursor +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import androidx.core.os.bundleOf +import com.dot.gallery.core.Constants +import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery +import com.dot.gallery.core.util.PickerUtils +import com.dot.gallery.core.util.Query +import com.dot.gallery.core.util.and +import com.dot.gallery.core.util.eq +import com.dot.gallery.core.util.join +import com.dot.gallery.core.util.MediaStoreBuckets +import com.dot.gallery.core.util.ext.mapEachRow +import com.dot.gallery.core.util.ext.queryFlow +import com.dot.gallery.core.util.ext.tryGetLong +import com.dot.gallery.core.util.ext.tryGetString +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaType +import com.dot.gallery.feature_node.presentation.util.getDate +import kotlinx.coroutines.flow.Flow + +/** + * Media flow + * + * This class is responsible for fetching media from the media store + * + * @property contentResolver + * @property buckedId + * @property mimeType + */ +class MediaFlow( + private val contentResolver: ContentResolver, + private val buckedId: Long, + private val mimeType: String? = null +) : QueryFlow() { + init { + assert(buckedId != MediaStoreBuckets.MEDIA_STORE_BUCKET_PLACEHOLDER.id) { + "MEDIA_STORE_BUCKET_PLACEHOLDER found" + } + } + + override fun flowCursor(): Flow { + val uri = MediaQuery.MediaStoreFileUri + val projection = MediaQuery.MediaProjection + val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let { + when (it) { + MediaType.IMAGE -> MediaQuery.Selection.image + MediaType.VIDEO -> MediaQuery.Selection.video + } + } ?: when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id -> MediaQuery.Selection.image + MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> MediaQuery.Selection.video + else -> MediaQuery.Selection.imageOrVideo + } + val albumFilter = when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1 + + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> + MediaStore.Files.FileColumns.IS_TRASHED eq 1 + + MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id, + MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id, + MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> null + + else -> MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG + } + val rawMimeType = mimeType?.takeIf { PickerUtils.isMimeTypeNotGeneric(it) } + val mimeTypeQuery = rawMimeType?.let { + MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG + } + + // Join all the non-null queries + val selection = listOfNotNull( + imageOrVideo, + albumFilter, + mimeTypeQuery, + ).join(Query::and) + + val selectionArgs = listOfNotNull( + buckedId.takeIf { + MediaStoreBuckets.entries.toTypedArray().none { bucket -> it == bucket.id } + }?.toString(), + rawMimeType, + ).toTypedArray() + + val sortOrder = when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> + "${MediaStore.Files.FileColumns.DATE_EXPIRES} DESC" + + else -> "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" + } + + val queryArgs = Bundle().apply { + putAll( + bundleOf( + ContentResolver.QUERY_ARG_SQL_SELECTION to selection?.build(), + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs, + ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder, + ) + ) + + // Exclude trashed media unless we want data for the trashed album + putInt( + MediaStore.QUERY_ARG_MATCH_TRASHED, when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY + + else -> MediaStore.MATCH_EXCLUDE + } + ) + } + + return contentResolver.queryFlow( + uri, + projection, + queryArgs, + ) + } + + override fun flowData() = flowCursor().mapEachRow(MediaQuery.MediaProjection) { it, indexCache -> + var i = 0 + + val id = it.getLong(indexCache[i++]) + val path = it.getString(indexCache[i++]) + val relativePath = it.getString(indexCache[i++]) + val title = it.getString(indexCache[i++]) + val albumID = it.getLong(indexCache[i++]) + val albumLabel = it.tryGetString(indexCache[i++], Build.MODEL) + val takenTimestamp = it.tryGetLong(indexCache[i++]) + val modifiedTimestamp = it.getLong(indexCache[i++]) + val duration = it.tryGetString(indexCache[i++]) + val size = it.getLong(indexCache[i++]) + val mimeType = it.getString(indexCache[i++]) + val isFavorite = it.getInt(indexCache[i++]) + val isTrashed = it.getInt(indexCache[i++]) + val expiryTimestamp = it.tryGetLong(indexCache[i]) + val contentUri = if (mimeType.contains("image")) + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + else + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + val uri = ContentUris.withAppendedId(contentUri, id) + val formattedDate = modifiedTimestamp.getDate(Constants.FULL_DATE_FORMAT) + Media( + id = id, + label = title, + uri = uri, + path = path, + relativePath = relativePath, + albumID = albumID, + albumLabel = albumLabel ?: Build.MODEL, + timestamp = modifiedTimestamp, + takenTimestamp = takenTimestamp, + expiryTimestamp = expiryTimestamp, + fullDate = formattedDate, + 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/data_source/mediastore/quries/MediaUriFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/MediaUriFlow.kt new file mode 100644 index 000000000..109114717 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/MediaUriFlow.kt @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.data.data_source.mediastore.quries + +import android.content.ContentResolver +import android.content.ContentUris +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import androidx.core.os.bundleOf +import com.dot.gallery.core.Constants +import com.dot.gallery.feature_node.data.data_source.mediastore.MediaQuery +import com.dot.gallery.core.util.PickerUtils +import com.dot.gallery.core.util.Query +import com.dot.gallery.core.util.and +import com.dot.gallery.core.util.eq +import com.dot.gallery.core.util.join +import com.dot.gallery.core.util.MediaStoreBuckets +import com.dot.gallery.core.util.ext.mapEachRow +import com.dot.gallery.core.util.ext.queryFlow +import com.dot.gallery.core.util.ext.tryGetLong +import com.dot.gallery.core.util.ext.tryGetString +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaType +import com.dot.gallery.feature_node.domain.model.isTrashed +import com.dot.gallery.feature_node.presentation.util.getDate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Media uri flow + * + * This class is responsible for fetching media from the media store based on the provided uris + * + * @property contentResolver + * @property mimeType + * @property uris + * @property reviewMode + */ +class MediaUriFlow( + private val contentResolver: ContentResolver, + private val mimeType: String? = null, + private val uris: List, + private val reviewMode: Boolean = false +) : QueryFlow() { + + private var buckedId: Long = MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id + + override fun flowCursor(): Flow { + val uri = MediaQuery.MediaStoreFileUri + val projection = MediaQuery.MediaProjection + val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let { + when (it) { + MediaType.IMAGE -> MediaQuery.Selection.image + MediaType.VIDEO -> MediaQuery.Selection.video + } + } ?: MediaQuery.Selection.imageOrVideo + val albumFilter = when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1 + + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> + MediaStore.Files.FileColumns.IS_TRASHED eq 1 + + MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id, + MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id, + MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> null + + else -> MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG + } + val rawMimeType = mimeType?.takeIf { PickerUtils.isMimeTypeNotGeneric(it) } + val mimeTypeQuery = rawMimeType?.let { + MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG + } + + // Join all the non-null queries + val selection = listOfNotNull( + imageOrVideo, + albumFilter, + mimeTypeQuery, + ).join(Query::and) + + val selectionArgs = listOfNotNull( + buckedId.takeIf { + MediaStoreBuckets.entries.toTypedArray().none { bucket -> it == bucket.id } + }?.toString(), + rawMimeType, + ).toTypedArray() + + val sortOrder = when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> + "${MediaStore.Files.FileColumns.DATE_EXPIRES} DESC" + + else -> "${MediaStore.Files.FileColumns.DATE_MODIFIED} DESC" + } + + val queryArgs = Bundle().apply { + putAll( + bundleOf( + ContentResolver.QUERY_ARG_SQL_SELECTION to selection?.build(), + ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs, + ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder, + ) + ) + + // Exclude trashed media unless we want data for the trashed album + putInt( + MediaStore.QUERY_ARG_MATCH_TRASHED, when (buckedId) { + MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY + + else -> MediaStore.MATCH_EXCLUDE + } + ) + } + + return contentResolver.queryFlow( + uri, + projection, + queryArgs, + ) + } + + override fun flowData() = + flowCursor().mapEachRow(MediaQuery.MediaProjection) { it, indexCache -> + var i = 0 + + val id = it.getLong(indexCache[i++]) + val path = it.getString(indexCache[i++]) + val relativePath = it.getString(indexCache[i++]) + val title = it.getString(indexCache[i++]) + val albumID = it.getLong(indexCache[i++]) + val albumLabel = it.tryGetString(indexCache[i++], Build.MODEL) + val takenTimestamp = it.tryGetLong(indexCache[i++]) + val modifiedTimestamp = it.getLong(indexCache[i++]) + val duration = it.tryGetString(indexCache[i++]) + val size = it.getLong(indexCache[i++]) + val mimeType = it.getString(indexCache[i++]) + val isFavorite = it.getInt(indexCache[i++]) + val isTrashed = it.getInt(indexCache[i++]) + val expiryTimestamp = it.tryGetLong(indexCache[i]) + val contentUri = if (mimeType.contains("image")) + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + else + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + val uri = ContentUris.withAppendedId(contentUri, id) + val formattedDate = modifiedTimestamp.getDate(Constants.FULL_DATE_FORMAT) + Media( + id = id, + label = title, + uri = uri, + path = path, + relativePath = relativePath, + albumID = albumID, + albumLabel = albumLabel ?: Build.MODEL, + timestamp = modifiedTimestamp, + takenTimestamp = takenTimestamp, + expiryTimestamp = expiryTimestamp, + fullDate = formattedDate, + duration = duration, + favorite = isFavorite, + trashed = isTrashed, + size = size, + mimeType = mimeType + ) + }.let { flow -> + val ids = uris.map { ContentUris.parseId(it) } + if (reviewMode) { + flow.map { mediaList -> + mediaList.filter { media -> + ids.contains(media.id) && !media.isTrashed + } + } + } else { + flow.map { mediaList -> + mediaList.filter { media -> + ids.contains(media.id) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/QueryFlow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/QueryFlow.kt new file mode 100644 index 000000000..7acc09ed9 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/mediastore/quries/QueryFlow.kt @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ +package com.dot.gallery.feature_node.data.data_source.mediastore.quries + +import android.database.Cursor +import kotlinx.coroutines.flow.Flow + +/** + * Query flow + * + * This class is responsible for fetching data with a cursor + * + * @param T + * @constructor Create empty Query flow + */ +abstract class QueryFlow { + + /** A flow of the data specified by the query */ + abstract fun flowData(): Flow> + + /** A flow of the cursor specified by the query */ + abstract fun flowCursor(): Flow +} \ No newline at end of file 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 deleted file mode 100644 index e0c802962..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetAlbums.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_types - -import android.content.ContentResolver -import android.content.ContentUris -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import com.dot.gallery.feature_node.data.data_source.Query -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -suspend fun ContentResolver.getAlbums( - query: Query = Query.AlbumQuery(), - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending) -): List { - return withContext(Dispatchers.IO) { - val timeStart = System.currentTimeMillis() - val albums = ArrayList() - val bundle = query.bundle ?: Bundle() - val albumQuery = query.copy( - bundle = bundle.apply { - putInt( - ContentResolver.QUERY_ARG_SORT_DIRECTION, - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) - putStringArray( - ContentResolver.QUERY_ARG_SORT_COLUMNS, - arrayOf(MediaStore.MediaColumns.DATE_MODIFIED) - ) - }, - ) - query(albumQuery).use { - with(it) { - while (moveToNext()) { - try { - val albumId = getLong(getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)) - val id = getLong(getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - val label: String? = try { - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME)) - } catch (e: Exception) { - Build.MODEL - } - val thumbnailPath = - getString(getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) - val thumbnailRelativePath = - 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")) - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - else - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - val album = Album( - id = albumId, - label = label ?: Build.MODEL, - uri = ContentUris.withAppendedId(contentUri, id), - pathToThumbnail = thumbnailPath, - relativePath = thumbnailRelativePath, - timestamp = thumbnailDate, - size = size, - count = 1 - ) - val currentAlbum = albums.find { albm -> albm.id == albumId } - if (currentAlbum == null) - albums.add(album) - 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 - } - } - - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - return@withContext mediaOrder.sortAlbums(albums).also { - println("Album parsing took: ${System.currentTimeMillis() - timeStart}ms") - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetFavoriteMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetFavoriteMedia.kt deleted file mode 100644 index f711c3c4f..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetFavoriteMedia.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_types - -import android.content.ContentResolver -import android.provider.MediaStore -import com.dot.gallery.feature_node.data.data_source.Query.Companion.defaultBundle -import com.dot.gallery.feature_node.data.data_source.Query.MediaQuery -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -suspend fun ContentResolver.getMediaFavorite( - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending) -): List { - return withContext(Dispatchers.IO) { - val mediaQuery = MediaQuery().copy( - bundle = defaultBundle.apply { - putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_ONLY) - } - ) - return@withContext mediaOrder.sortMedia(getMedia(mediaQuery)) - } -} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetMedia.kt deleted file mode 100644 index 9de73de10..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetMedia.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_types - -import android.content.ContentResolver -import com.dot.gallery.feature_node.data.data_source.Query -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -suspend fun ContentResolver.getMedia( - mediaQuery: Query = Query.MediaQuery(), - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending) -): List { - return withContext(Dispatchers.IO) { - val timeStart = System.currentTimeMillis() - val media = ArrayList() - query(mediaQuery).use { cursor -> - while (cursor.moveToNext()) { - try { - media.add(cursor.getMediaFromCursor()) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - return@withContext mediaOrder.sortMedia(media).also { - println("Media parsing took: ${System.currentTimeMillis() - timeStart}ms") - } - } -} - -suspend fun ContentResolver.findMedia(mediaQuery: Query): Media? { - return withContext(Dispatchers.IO) { - val mediaList = getMedia(mediaQuery) - return@withContext if (mediaList.isEmpty()) null else mediaList.first() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetMediaByUri.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetMediaByUri.kt deleted file mode 100644 index a4e254ba5..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetMediaByUri.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_types - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.os.Bundle -import android.provider.MediaStore -import com.dot.gallery.feature_node.data.data_source.Query.MediaQuery -import com.dot.gallery.feature_node.domain.model.Media -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -suspend fun Context.getMediaByUri(uri: Uri): Media? { - return withContext(Dispatchers.IO) { - var media: Media? = null - val mediaQuery = MediaQuery().copy( - bundle = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.DATA + "=?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(uri.toString()) - ) - } - ) - with(contentResolver.query(mediaQuery)) { - moveToFirst() - while (!isAfterLast) { - try { - media = getMediaFromCursor() - break - } catch (e: Exception) { - close() - e.printStackTrace() - } - } - moveToNext() - close() - } - if (media == null) { - media = Media.createFromUri(this@getMediaByUri, uri) - } - - return@withContext media - } -} - -suspend fun Context.getMediaListByUris(list: List): List { - return withContext(Dispatchers.IO) { - val mediaList = ArrayList() - val mediaQuery = MediaQuery().copy( - bundle = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns._ID + "=?" - ) - putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - list.map { it.toString().substringAfterLast("/") }.toTypedArray() - ) - } - ) - mediaList.addAll(contentResolver.getMedia(mediaQuery)) - if (mediaList.isEmpty()) { - for (uri in list) { - Media.createFromUri(this@getMediaListByUris, uri)?.let { mediaList.add(it) } - } - } - mediaList - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetTrashedMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetTrashedMedia.kt deleted file mode 100644 index be5c2684a..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/GetTrashedMedia.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.data.data_types - -import android.content.ContentResolver -import android.os.Bundle -import android.provider.MediaStore -import com.dot.gallery.feature_node.data.data_source.Query.TrashQuery -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.util.MediaOrder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -suspend fun ContentResolver.getMediaTrashed(): List { - return withContext(Dispatchers.IO) { - val mediaQuery = TrashQuery().copy( - bundle = Bundle().apply { - putStringArray( - ContentResolver.QUERY_ARG_SORT_COLUMNS, - arrayOf(MediaStore.MediaColumns.DATE_EXPIRES) - ) - putInt( - ContentResolver.QUERY_ARG_SQL_SORT_ORDER, - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) - putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) - } - ) - return@withContext MediaOrder.Expiry().sortMedia(getMedia(mediaQuery)) - } -} - 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 7ca16b8e1..7c07703f5 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 @@ -12,102 +12,116 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import android.os.Bundle import android.os.Environment import android.provider.MediaStore import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.core.app.ActivityOptionsCompat +import androidx.work.WorkManager 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.core.updateDatabase +import com.dot.gallery.core.util.MediaStoreBuckets +import com.dot.gallery.core.util.ext.copyMedia +import com.dot.gallery.core.util.ext.mapAsResource +import com.dot.gallery.core.util.ext.overrideImage +import com.dot.gallery.core.util.ext.saveImage +import com.dot.gallery.core.util.ext.updateMedia +import com.dot.gallery.core.util.ext.updateMediaExif 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.data_source.KeychainHolder.Companion.VAULT_INFO_FILE_NAME -import com.dot.gallery.feature_node.data.data_source.Query -import com.dot.gallery.feature_node.data.data_types.copyMedia -import com.dot.gallery.feature_node.data.data_types.findMedia -import com.dot.gallery.feature_node.data.data_types.getAlbums -import com.dot.gallery.feature_node.data.data_types.getMedia -import com.dot.gallery.feature_node.data.data_types.getMediaByUri -import com.dot.gallery.feature_node.data.data_types.getMediaFavorite -import com.dot.gallery.feature_node.data.data_types.getMediaListByUris -import com.dot.gallery.feature_node.data.data_types.getMediaTrashed -import com.dot.gallery.feature_node.data.data_types.overrideImage -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.data.data_source.mediastore.quries.AlbumsFlow +import com.dot.gallery.feature_node.data.data_source.mediastore.quries.MediaFlow +import com.dot.gallery.feature_node.data.data_source.mediastore.quries.MediaUriFlow import com.dot.gallery.feature_node.domain.model.Album 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.TimelineSettings import com.dot.gallery.feature_node.domain.model.Vault import com.dot.gallery.feature_node.domain.model.toEncryptedMedia import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.BOTH import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.PHOTOS import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.VIDEOS +import com.dot.gallery.feature_node.presentation.util.printError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import java.io.File -import java.io.IOException import java.io.Serializable class MediaRepositoryImpl( private val context: Context, + private val workManager: WorkManager, private val database: InternalDatabase, private val keychainHolder: KeychainHolder ) : MediaRepository { + private val contentResolver = context.contentResolver + + override suspend fun updateInternalDatabase() { + workManager.updateDatabase() + } + /** * TODO: Add media reordering */ override fun getMedia(): Flow>> = - context.retrieveMedia(database) { - it.getMedia(mediaOrder = DEFAULT_ORDER).removeBlacklisted() - } + MediaFlow( + contentResolver = contentResolver, + buckedId = MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id + ).flowData().map { + Resource.Success(it) + }.flowOn(Dispatchers.IO) override fun getMediaByType(allowedMedia: AllowedMedia): Flow>> = - context.retrieveMedia(database) { - val query = when (allowedMedia) { - PHOTOS -> Query.PhotoQuery() - VIDEOS -> Query.VideoQuery() - BOTH -> Query.MediaQuery() - } - it.getMedia(mediaQuery = query, mediaOrder = DEFAULT_ORDER).removeBlacklisted() - } + MediaFlow( + contentResolver = contentResolver, + buckedId = when (allowedMedia) { + PHOTOS -> MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id + VIDEOS -> MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id + BOTH -> MediaStoreBuckets.MEDIA_STORE_BUCKET_TIMELINE.id + }, + mimeType = allowedMedia.toStringAny() + ).flowData().map { + Resource.Success(it) + }.flowOn(Dispatchers.IO) override fun getFavorites(mediaOrder: MediaOrder): Flow>> = - context.retrieveMedia(database) { - it.getMediaFavorite(mediaOrder = mediaOrder).removeBlacklisted() - } + MediaFlow( + contentResolver = contentResolver, + buckedId = MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id + ).flowData().map { + Resource.Success(it) + }.flowOn(Dispatchers.IO) override fun getTrashed(): Flow>> = - context.retrieveMedia(database) { - it.getMediaTrashed().removeBlacklisted() - } - - override fun getAlbums( - mediaOrder: MediaOrder, - ignoreBlacklisted: Boolean - ): Flow>> = - context.retrieveAlbums(database) { cr -> - cr.getAlbums(mediaOrder = mediaOrder).toMutableList().apply { - replaceAll { album -> - album.copy(isPinned = database.getPinnedDao().albumIsPinned(album.id)) + MediaFlow( + contentResolver = contentResolver, + buckedId = MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id + ).flowData().map { Resource.Success(it) }.flowOn(Dispatchers.IO) + + override fun getAlbums(mediaOrder: MediaOrder): Flow>> = + AlbumsFlow(context).flowData().map { + withContext(Dispatchers.IO) { + val data = it.toMutableList().apply { + replaceAll { album -> + album.copy(isPinned = database.getPinnedDao().albumIsPinned(album.id)) + } } - }.removeBlacklisted(ignoreBlacklisted) - } + + Resource.Success(mediaOrder.sortAlbums(data)) + } + }.flowOn(Dispatchers.IO) override suspend fun insertPinnedAlbum(pinnedAlbum: PinnedAlbum) = database.getPinnedDao().insertPinnedAlbum(pinnedAlbum) @@ -115,6 +129,9 @@ class MediaRepositoryImpl( override suspend fun removePinnedAlbum(pinnedAlbum: PinnedAlbum) = database.getPinnedDao().removePinnedAlbum(pinnedAlbum) + override fun getPinnedAlbums(): Flow> = + database.getPinnedDao().getPinnedAlbums() + override suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) = database.getBlacklistDao().addBlacklistedAlbum(ignoredAlbum) @@ -124,144 +141,37 @@ class MediaRepositoryImpl( override fun getBlacklistedAlbums(): Flow> = database.getBlacklistDao().getBlacklistedAlbums() - override suspend fun getMediaById(mediaId: Long): Media? { - val query = Query.MediaQuery().copy( - bundle = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns._ID + "= ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(mediaId.toString()) - ) - } - ) - return context.contentResolver.findMedia(query) - } - override fun getMediaByAlbumId(albumId: Long): Flow>> = - context.retrieveMedia(database) { - val query = Query.MediaQuery().copy( - bundle = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.BUCKET_ID + "= ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(albumId.toString()) - ) - } - ) - /** return@retrieveMedia */ - it.getMedia(query) - } + MediaFlow( + contentResolver = contentResolver, + buckedId = albumId, + ).flowData().mapAsResource() override fun getMediaByAlbumIdWithType( albumId: Long, allowedMedia: AllowedMedia ): Flow>> = - context.retrieveMedia(database) { - val query = Query.MediaQuery().copy( - bundle = Bundle().apply { - val mimeType = when (allowedMedia) { - PHOTOS -> "image%" - VIDEOS -> "video%" - BOTH -> "%/%" - } - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.BUCKET_ID + "= ? and " + MediaStore.MediaColumns.MIME_TYPE + " like ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(albumId.toString(), mimeType) - ) - } - ) - /** return@retrieveMedia */ - it.getMedia(query) - } + MediaFlow( + contentResolver = contentResolver, + buckedId = albumId, + mimeType = allowedMedia.toStringAny() + ).flowData().mapAsResource() override fun getAlbumsWithType(allowedMedia: AllowedMedia): Flow>> = - context.retrieveAlbums(database) { - val query = Query.AlbumQuery().copy( - bundle = Bundle().apply { - val mimeType = when (allowedMedia) { - PHOTOS -> "image%" - VIDEOS -> "video%" - BOTH -> "%/%" - } - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.MIME_TYPE + " like ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(mimeType) - ) - } - ) - it.getAlbums(query, mediaOrder = MediaOrder.Label(OrderType.Ascending)) - } - - override fun getMediaByUri( - uriAsString: String, - isSecure: Boolean - ): Flow>> = - context.retrieveMediaAsResource { - val media = context.getMediaByUri(Uri.parse(uriAsString)) - /** return@retrieveMediaAsResource */ - if (media == null) { - Resource.Error(message = "Media could not be opened") - } else { - val query = Query.MediaQuery().copy( - bundle = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.BUCKET_ID + "= ?" - ) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(media.albumID.toString()) - ) - } - ) - Resource.Success( - data = if (isSecure) listOf(media) else it.getMedia(query) - .ifEmpty { listOf(media) }) - } - } + AlbumsFlow( + context = context, + mimeType = allowedMedia.toStringAny() + ).flowData().mapAsResource() override fun getMediaListByUris( listOfUris: List, reviewMode: Boolean ): Flow>> = - context.retrieveMediaAsResource { - var mediaList = context.getMediaListByUris(listOfUris) - if (reviewMode) { - val query = Query.MediaQuery().copy( - bundle = Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - MediaStore.MediaColumns.BUCKET_ID + "= ?" - ) - putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) - putStringArray( - ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf(mediaList.first().albumID.toString()) - ) - } - ) - mediaList = it.getMedia(query).filter { media -> !media.isTrashed } - } - if (mediaList.isEmpty()) { - Resource.Error(message = "Media could not be opened") - } else { - Resource.Success(data = mediaList) - } - } + MediaUriFlow( + contentResolver = contentResolver, + uris = listOfUris, + reviewMode = reviewMode + ).flowData().mapAsResource(errorOnEmpty = true, errorMessage = "Media could not be opened") override suspend fun toggleFavorite( result: ActivityResultLauncher, @@ -269,7 +179,7 @@ class MediaRepositoryImpl( favorite: Boolean ) { val intentSender = MediaStore.createFavoriteRequest( - context.contentResolver, + contentResolver, mediaList.map { it.uri }, favorite ).intentSender @@ -285,7 +195,7 @@ class MediaRepositoryImpl( trash: Boolean ) { val intentSender = MediaStore.createTrashRequest( - context.contentResolver, + contentResolver, mediaList.map { it.uri }, trash ).intentSender @@ -301,7 +211,7 @@ class MediaRepositoryImpl( ) { val intentSender = MediaStore.createDeleteRequest( - context.contentResolver, + contentResolver, mediaList.map { it.uri }).intentSender val senderRequest: IntentSenderRequest = IntentSenderRequest.Builder(intentSender) .setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 0) @@ -312,7 +222,7 @@ class MediaRepositoryImpl( override suspend fun copyMedia( from: Media, path: String - ): Boolean = context.contentResolver.copyMedia( + ): Boolean = contentResolver.copyMedia( from = from, path = path ) @@ -320,7 +230,7 @@ class MediaRepositoryImpl( override suspend fun renameMedia( media: Media, newName: String - ): Boolean = context.contentResolver.updateMedia( + ): Boolean = contentResolver.updateMedia( media = media, contentValues = displayName(newName) ) @@ -328,7 +238,7 @@ class MediaRepositoryImpl( override suspend fun moveMedia( media: Media, newPath: String - ): Boolean = context.contentResolver.updateMedia( + ): Boolean = contentResolver.updateMedia( media = media, contentValues = relativePath(newPath) ) @@ -336,7 +246,7 @@ class MediaRepositoryImpl( override suspend fun updateMediaExif( media: Media, exifAttributes: ExifAttributes - ): Boolean = context.contentResolver.updateMediaExif( + ): Boolean = contentResolver.updateMediaExif( media = media, exifAttributes = exifAttributes ) @@ -347,7 +257,7 @@ class MediaRepositoryImpl( mimeType: String, relativePath: String, displayName: String - ) = context.contentResolver.saveImage(bitmap, format, mimeType, relativePath, displayName) + ) = contentResolver.saveImage(bitmap, format, mimeType, relativePath, displayName) override fun overrideImage( uri: Uri, @@ -356,7 +266,7 @@ class MediaRepositoryImpl( mimeType: String, relativePath: String, displayName: String - ) = context.contentResolver.overrideImage(uri, bitmap, format, mimeType, relativePath, displayName) + ) = contentResolver.overrideImage(uri, bitmap, format, mimeType, relativePath, displayName) override fun getVaults(): Flow>> = context.retrieveInternalFiles { @@ -379,40 +289,18 @@ class MediaRepositoryImpl( vault: Vault, onSuccess: () -> Unit, onFailed: (reason: String) -> Unit - ) { - var collected = false - getVaults().collectLatest { - if (!collected) { - collected = true - val alreadyExists = it.data?.contains(vault) ?: false - if (alreadyExists) { - onFailed("Vault \"${vault.name}\" exists") - } else { - keychainHolder.writeVaultInfo(vault, onSuccess) - } - } - } - } + ) = withContext(Dispatchers.IO) { keychainHolder.writeVaultInfo(vault, onSuccess, onFailed) } override suspend fun deleteVault( vault: Vault, onSuccess: () -> Unit, onFailed: (reason: String) -> Unit - ) { - try { - with(keychainHolder) { vaultFolder(vault).deleteRecursively() } - } catch (e: IOException) { - e.printStackTrace() - onFailed("Failed to delete vault ${vault.name} (${vault.uuid})") - return - } - onSuccess() - } + ) = withContext(Dispatchers.IO) { keychainHolder.deleteVault(vault, onSuccess, onFailed) } override fun getEncryptedMedia(vault: Vault): Flow>> = context.retrieveInternalFiles { with(keychainHolder) { - vaultFolder(vault).listFiles()?.filter { + (vaultFolder(vault).listFiles()?.filter { it.name.endsWith("enc") }?.mapNotNull { try { @@ -421,7 +309,7 @@ class MediaRepositoryImpl( } catch (e: Exception) { null } - } ?: emptyList() + } ?: emptyList()).sortedByDescending { it.timestamp } } } @@ -440,6 +328,7 @@ class MediaRepositoryImpl( true } catch (e: Exception) { e.printStackTrace() + printError("Failed to add file: ${media.label}") false } } @@ -463,6 +352,7 @@ class MediaRepositoryImpl( restored && deleted } catch (e: Exception) { e.printStackTrace() + printError("Failed to restore file: ${media.label}") false } } @@ -476,6 +366,7 @@ class MediaRepositoryImpl( vault.mediaFile(media.id).delete() } catch (e: Exception) { e.printStackTrace() + printError("Failed to delete file: ${media.label}") false } } @@ -495,6 +386,7 @@ class MediaRepositoryImpl( file.delete() } catch (e: Exception) { e.printStackTrace() + printError("Failed to delete file: ${file.name}") failedFiles.add(file) } } @@ -508,25 +400,12 @@ class MediaRepositoryImpl( } } - private fun List.removeBlacklisted(): List = toMutableList().apply { - 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) } } - } + override fun getSettings(): Flow = database.getMediaDao().getTimelineSettings() + override suspend fun updateSettings(settings: TimelineSettings) { + database.getMediaDao().setTimelineSettings(settings) } companion object { - private val DEFAULT_ORDER = MediaOrder.Date(OrderType.Descending) - private val URIs = arrayOf( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - ) - private fun displayName(newName: String) = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, newName) } @@ -535,33 +414,6 @@ class MediaRepositoryImpl( put(MediaStore.MediaColumns.RELATIVE_PATH, newPath) } - private fun Context.retrieveMediaAsResource(dataBody: suspend (ContentResolver) -> Resource>) = - contentFlowObserver(URIs).map { - try { - dataBody.invoke(contentResolver) - } catch (e: Exception) { - Resource.Error(message = e.localizedMessage ?: "An error occurred") - } - }.conflate() - - private fun Context.retrieveMedia(database: InternalDatabase, dataBody: suspend (ContentResolver) -> List) = - contentFlowWithDatabase(URIs, database).map { - try { - Resource.Success(data = dataBody.invoke(contentResolver)) - } catch (e: Exception) { - Resource.Error(message = e.localizedMessage ?: "An error occurred") - } - }.conflate() - - private fun Context.retrieveAlbums(database: InternalDatabase, dataBody: suspend (ContentResolver) -> List) = - contentFlowWithDatabase(URIs, database).map { - try { - Resource.Success(data = dataBody.invoke(contentResolver)) - } catch (e: Exception) { - Resource.Error(message = e.localizedMessage ?: "An error occurred") - } - }.conflate() - private fun Context.retrieveInternalFiles(dataBody: suspend (ContentResolver) -> List) = fileFlowObserver().map { try { 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 b1486343c..12fc28c6e 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 @@ -24,7 +24,6 @@ data class Album( val timestamp: Long, var count: Long = 0, var size: Long = 0, - val selected: Boolean = false, val isPinned: Boolean = false, ) : Parcelable { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt new file mode 100644 index 000000000..65edb1fb4 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/AlbumState.kt @@ -0,0 +1,13 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable + +@Stable +data class AlbumState( + val albums: List = emptyList(), + val albumsWithBlacklisted: List = emptyList(), + val albumsUnpinned: List = emptyList(), + val albumsPinned: List = emptyList(), + val error: String = "", + val isLoading: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMediaState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMediaState.kt new file mode 100644 index 000000000..dc612c381 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMediaState.kt @@ -0,0 +1,14 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable + +@Stable +data class EncryptedMediaState( + val media: List = emptyList(), + val mappedMedia: List = emptyList(), + val mappedMediaWithMonthly: List = emptyList(), + val headers: List = emptyList(), + val dateHeader: String = "", + val error: String = "", + val isLoading: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/InfoRow.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/InfoRow.kt new file mode 100644 index 000000000..fc5f23e2a --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/InfoRow.kt @@ -0,0 +1,15 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.vector.ImageVector + +@Stable +data class InfoRow( + val label: String, + val content: String, + val icon: ImageVector, + val trailingIcon: ImageVector? = null, + val contentDescription: String? = null, + val onClick: (() -> Unit)? = null, + val onLongClick: (() -> Unit)? = null, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LibraryIndicatorState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LibraryIndicatorState.kt new file mode 100644 index 000000000..04094d8bb --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LibraryIndicatorState.kt @@ -0,0 +1,11 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable + +@Stable +@Serializable +data class LibraryIndicatorState( + val trashCount: Int = 0, + val favoriteCount: Int = 0, +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt new file mode 100644 index 000000000..76b6ea151 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/LocationData.kt @@ -0,0 +1,59 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.dot.gallery.feature_node.presentation.util.ExifMetadata +import com.dot.gallery.feature_node.presentation.util.formattedAddress +import com.dot.gallery.feature_node.presentation.util.getLocation +import com.dot.gallery.feature_node.presentation.util.rememberGeocoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Stable +data class LocationData( + val latitude: Double, + val longitude: Double, + val location: String +) + +@Composable +fun rememberLocationData( + exifMetadata: ExifMetadata?, + media: Media +): LocationData? { + val geocoder = rememberGeocoder() + var locationName by remember { mutableStateOf(exifMetadata?.formattedCords) } + LaunchedEffect(geocoder, exifMetadata) { + withContext(Dispatchers.IO) { + if (exifMetadata?.gpsLatLong != null) { + geocoder?.getLocation( + exifMetadata.gpsLatLong[0], + exifMetadata.gpsLatLong[1] + ) { address -> + address?.let { + val addressName = it.formattedAddress + if (addressName.isNotEmpty()) { + locationName = addressName + } + } + } + } + } + } + return remember(media, exifMetadata, locationName) { + exifMetadata?.let { + it.gpsLatLong?.let { latLong -> + LocationData( + latitude = latLong[0], + longitude = latLong[1], + location = locationName ?: "Unknown" + ) + } + } + } +} \ No newline at end of file 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 e1b11720f..4d0e79f89 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,15 +12,16 @@ import android.os.Parcelable import android.webkit.MimeTypeMap import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.room.Entity import com.dot.gallery.core.Constants import com.dot.gallery.feature_node.presentation.util.getDate -import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.io.File import kotlin.random.Random @Immutable @Parcelize +@Entity(tableName = "media", primaryKeys = ["id"]) data class Media( val id: Long = 0, val label: String, @@ -40,84 +41,11 @@ data class Media( val duration: String? = null, ) : Parcelable { - @IgnoredOnParcel - @Stable - val isVideo: Boolean = mimeType.startsWith("video/") && duration != null - - @IgnoredOnParcel - @Stable - val isImage: Boolean = mimeType.startsWith("image/") - - @IgnoredOnParcel - @Stable - val isTrashed: Boolean = trashed == 1 - - @IgnoredOnParcel - @Stable - val isFavorite: Boolean = favorite == 1 - @Stable override fun toString(): String { return "$id, $path, $fullDate, $mimeType, favorite=$favorite" } - /** - * Used to determine if the Media object is not accessible - * via MediaStore. - * This happens when the user tries to open media from an app - * using external sources (in our case, Gallery Media Viewer), but - * the specific media is only available internally in that app - * (Android/data(OR media)/com.package.name/) - * - * If it's readUriOnly then we know that we should expect a barebone - * Media object with limited functionality (no favorites, trash, timestamp etc) - */ - @IgnoredOnParcel - @Stable - val readUriOnly: Boolean = albumID == -99L && albumLabel == "" - - /** - * Determine if the current media is a raw format - * - * Checks if [mimeType] starts with "image/x-" or "image/vnd." - * - * Most used formats: - * - ARW: image/x-sony-arw - * - CR2: image/x-canon-cr2 - * - CRW: image/x-canon-crw - * - DCR: image/x-kodak-dcr - * - DNG: image/x-adobe-dng - * - ERF: image/x-epson-erf - * - K25: image/x-kodak-k25 - * - KDC: image/x-kodak-kdc - * - MRW: image/x-minolta-mrw - * - NEF: image/x-nikon-nef - * - ORF: image/x-olympus-orf - * - PEF: image/x-pentax-pef - * - RAF: image/x-fuji-raf - * - RAW: image/x-panasonic-raw - * - SR2: image/x-sony-sr2 - * - SRF: image/x-sony-srf - * - X3F: image/x-sigma-x3f - * - * Other proprietary image types in the standard: - * image/vnd.manufacturer.filename_extension for instance for NEF by Nikon and .mrv for Minolta: - * - NEF: image/vnd.nikon.nef - * - Minolta: image/vnd.minolta.mrw - */ - @IgnoredOnParcel - @Stable - val isRaw: Boolean = - mimeType.isNotBlank() && (mimeType.startsWith("image/x-") || mimeType.startsWith("image/vnd.")) - - @IgnoredOnParcel - @Stable - val fileExtension: String = label.substringAfterLast(".").removePrefix(".") - - @IgnoredOnParcel - @Stable - val volume: String = path.substringBeforeLast("/").removeSuffix(relativePath.removeSuffix("/")) - companion object { fun createFromUri(context: Context, uri: Uri): Media? { if (uri.path == null) return null @@ -171,3 +99,60 @@ data class Media( } } } + +/** + * Determine if the current media is a raw format + * + * Checks if [Media.mimeType] starts with "image/x-" or "image/vnd." + * + * Most used formats: + * - ARW: image/x-sony-arw + * - CR2: image/x-canon-cr2 + * - CRW: image/x-canon-crw + * - DCR: image/x-kodak-dcr + * - DNG: image/x-adobe-dng + * - ERF: image/x-epson-erf + * - K25: image/x-kodak-k25 + * - KDC: image/x-kodak-kdc + * - MRW: image/x-minolta-mrw + * - NEF: image/x-nikon-nef + * - ORF: image/x-olympus-orf + * - PEF: image/x-pentax-pef + * - RAF: image/x-fuji-raf + * - RAW: image/x-panasonic-raw + * - SR2: image/x-sony-sr2 + * - SRF: image/x-sony-srf + * - X3F: image/x-sigma-x3f + * + * Other proprietary image types in the standard: + * image/vnd.manufacturer.filename_extension for instance for NEF by Nikon and .mrv for Minolta: + * - NEF: image/vnd.nikon.nef + * - Minolta: image/vnd.minolta.mrw + */ +val Media.isRaw: Boolean get() = + mimeType.isNotBlank() && (mimeType.startsWith("image/x-") || mimeType.startsWith("image/vnd.")) + +val Media.fileExtension: String get() = label.substringAfterLast(".").removePrefix(".") + +val Media.volume: String get() = path.substringBeforeLast("/").removeSuffix(relativePath.removeSuffix("/")) + +/** + * Used to determine if the Media object is not accessible + * via MediaStore. + * This happens when the user tries to open media from an app + * using external sources (in our case, Gallery Media Viewer), but + * the specific media is only available internally in that app + * (Android/data(OR media)/com.package.name/) + * + * If it's readUriOnly then we know that we should expect a barebone + * Media object with limited functionality (no favorites, trash, timestamp etc) + */ +val Media.readUriOnly: Boolean get() = albumID == -99L && albumLabel == "" + +val Media.isVideo: Boolean get() = mimeType.startsWith("video/") && duration != null + +val Media.isImage: Boolean get() = mimeType.startsWith("image/") + +val Media.isTrashed: Boolean get() = trashed == 1 + +val Media.isFavorite: Boolean get() = favorite == 1 diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt new file mode 100644 index 000000000..9f26d4d6e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaDateCaption.kt @@ -0,0 +1,51 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.dot.gallery.R +import com.dot.gallery.core.Constants +import com.dot.gallery.feature_node.presentation.util.ExifMetadata +import com.dot.gallery.feature_node.presentation.util.getDate + +@Stable +data class MediaDateCaption( + val date: String, + val deviceInfo: String? = null, + val description: String +) + +@Composable +fun rememberMediaDateCaption( + exifMetadata: ExifMetadata?, + media: Media +): MediaDateCaption { + val deviceInfo = remember(exifMetadata) { exifMetadata?.lensDescription } + val defaultDesc = stringResource(R.string.image_add_description) + val description = remember(exifMetadata) { exifMetadata?.imageDescription ?: defaultDesc } + return remember(media) { + MediaDateCaption( + date = media.timestamp.getDate(Constants.EXIF_DATE_FORMAT), + deviceInfo = deviceInfo, + description = description + ) + } +} + +@Composable +fun rememberMediaDateCaption( + exifMetadata: ExifMetadata?, + media: EncryptedMedia +): MediaDateCaption { + val deviceInfo = remember(exifMetadata) { exifMetadata?.lensDescription } + val defaultDesc = stringResource(R.string.image_add_description) + val description = remember(exifMetadata) { exifMetadata?.imageDescription ?: defaultDesc } + return remember(media) { + MediaDateCaption( + date = media.timestamp.getDate(Constants.EXIF_DATE_FORMAT), + deviceInfo = deviceInfo, + description = description + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt index 06d684bd5..4c7dc369d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt @@ -4,25 +4,20 @@ */ package com.dot.gallery.feature_node.domain.model -import android.os.Parcelable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import kotlinx.parcelize.Parcelize -@Parcelize -@Immutable -sealed class MediaItem : Parcelable { - @Stable +@Stable +sealed class MediaItem { abstract val key: String - @Immutable + @Stable data class Header( override val key: String, val text: String, - val data: List + val data: Set ) : MediaItem() - @Immutable + @Stable data class MediaViewItem( override val key: String, val media: Media @@ -30,20 +25,18 @@ sealed class MediaItem : Parcelable { } -@Parcelize -@Immutable -sealed class EncryptedMediaItem : Parcelable { - @Stable +@Stable +sealed class EncryptedMediaItem { abstract val key: String - @Immutable + @Stable data class Header( override val key: String, val text: String, - val data: List + val data: Set ) : EncryptedMediaItem() - @Immutable + @Stable data class MediaViewItem( override val key: String, val media: EncryptedMedia diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt new file mode 100644 index 000000000..f5cb4ab5f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaState.kt @@ -0,0 +1,14 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable + +@Stable +data class MediaState( + val media: List = emptyList(), + val mappedMedia: List = emptyList(), + val mappedMediaWithMonthly: List = emptyList(), + val headers: List = emptyList(), + val dateHeader: String = "", + val error: String = "", + val isLoading: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaType.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaType.kt new file mode 100644 index 000000000..cfb3970ad --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaType.kt @@ -0,0 +1,48 @@ +package com.dot.gallery.feature_node.domain.model + +import android.net.Uri +import android.provider.MediaStore + +enum class MediaType( + val externalContentUri: Uri, + val mediaStoreValue: Int, +) { + IMAGE( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE, + ), + VIDEO( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO, + ); + + companion object { + private val dashMimeTypes = listOf( + "application/dash+xml", + ) + + private val hlsMimeTypes = listOf( + "application/vnd.apple.mpegurl", + "application/x-mpegurl", + "audio/mpegurl", + "audio/x-mpegurl", + ) + + private val smoothStreamingMimeTypes = listOf( + "application/vnd.ms-sstr+xml", + ) + + fun fromMediaStoreValue(value: Int) = entries.first { + value == it.mediaStoreValue + } + + fun fromMimeType(mimeType: String) = when { + mimeType.startsWith("image/") -> IMAGE + mimeType.startsWith("video/") -> VIDEO + mimeType in dashMimeTypes -> VIDEO + mimeType in hlsMimeTypes -> VIDEO + mimeType in smoothStreamingMimeTypes -> VIDEO + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaVersion.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaVersion.kt new file mode 100644 index 000000000..64314ffc6 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaVersion.kt @@ -0,0 +1,8 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.room.Entity + +@Entity(tableName = "media_version", primaryKeys = ["version"]) +data class MediaVersion( + val version: String +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/TimelineSettings.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/TimelineSettings.kt new file mode 100644 index 000000000..fde681e64 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/TimelineSettings.kt @@ -0,0 +1,29 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.dot.gallery.feature_node.domain.util.MediaOrder +import com.dot.gallery.feature_node.domain.util.OrderType + +/** + * Timeline settings + * + * This entity contains all settings of the app that + * affects the media display. + */ +@Stable +@Entity(tableName = "timeline_settings") +data class TimelineSettings( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + @ColumnInfo(defaultValue = "0") + val groupTimelineByMonth: Boolean = false, + @ColumnInfo(defaultValue = "0") + val groupTimelineInAlbums: Boolean = false, + @ColumnInfo(defaultValue = "{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}") + val timelineMediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending), + @ColumnInfo(defaultValue = "{\"orderType\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"},\"orderType_date\":{\"type\":\"com.dot.gallery.feature_node.domain.util.OrderType.Descending\"}}") + val albumMediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending), +) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultState.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultState.kt new file mode 100644 index 000000000..f2a2103bf --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/VaultState.kt @@ -0,0 +1,19 @@ +package com.dot.gallery.feature_node.domain.model + +import androidx.compose.runtime.Stable +import com.dot.gallery.feature_node.presentation.vault.VaultScreens + +@Stable +data class VaultState( + val vaults: List = emptyList(), + val isLoading: Boolean = true +) { + + fun getStartScreen(): String { + return (if (isLoading) VaultScreens.LoadingScreen else if (vaults.isEmpty()) { + VaultScreens.VaultSetup + } else { + VaultScreens.VaultDisplay + }).invoke() + } +} \ 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 d6b6c953a..08776055b 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,11 +11,12 @@ 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.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.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.TimelineSettings import com.dot.gallery.feature_node.domain.model.Vault import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.presentation.picker.AllowedMedia @@ -24,6 +25,8 @@ import java.io.File interface MediaRepository { + suspend fun updateInternalDatabase() + fun getMedia(): Flow>> fun getMediaByType(allowedMedia: AllowedMedia): Flow>> @@ -32,23 +35,20 @@ interface MediaRepository { fun getTrashed(): Flow>> - fun getAlbums( - mediaOrder: MediaOrder, - ignoreBlacklisted: Boolean = false - ): Flow>> + fun getAlbums(mediaOrder: MediaOrder): Flow>> suspend fun insertPinnedAlbum(pinnedAlbum: PinnedAlbum) suspend fun removePinnedAlbum(pinnedAlbum: PinnedAlbum) + fun getPinnedAlbums(): Flow> + suspend fun addBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) suspend fun removeBlacklistedAlbum(ignoredAlbum: IgnoredAlbum) fun getBlacklistedAlbums(): Flow> - suspend fun getMediaById(mediaId: Long): Media? - fun getMediaByAlbumId(albumId: Long): Flow>> fun getMediaByAlbumIdWithType( @@ -58,8 +58,6 @@ interface MediaRepository { fun getAlbumsWithType(allowedMedia: AllowedMedia): Flow>> - fun getMediaByUri(uriAsString: String, isSecure: Boolean): Flow>> - fun getMediaListByUris(listOfUris: List, reviewMode: Boolean): Flow>> suspend fun toggleFavorite( @@ -144,4 +142,8 @@ interface MediaRepository { onFailed: (failedFiles: List) -> Unit ): Boolean + fun getSettings(): Flow + + suspend fun updateSettings(settings: TimelineSettings) + } \ No newline at end of file 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 deleted file mode 100644 index 691463773..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/BlacklistUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.dot.gallery.feature_node.domain.use_case - -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(ignoredAlbum: IgnoredAlbum) = - repository.addBlacklistedAlbum(ignoredAlbum) - - suspend fun removeFromBlacklist(ignoredAlbum: IgnoredAlbum) = - repository.removeBlacklistedAlbum(ignoredAlbum) - - val blacklistedAlbums by lazy { repository.getBlacklistedAlbums() } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/DeletePinnedAlbumUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/DeletePinnedAlbumUseCase.kt deleted file mode 100644 index 5aa14754e..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/DeletePinnedAlbumUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.repository.MediaRepository - -class DeletePinnedAlbumUseCase( - private val repository: MediaRepository -) { - - suspend operator fun invoke( - album: Album - ) = repository.removePinnedAlbum(PinnedAlbum(album.id)) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetAlbumsUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetAlbumsUseCase.kt deleted file mode 100644 index db287640e..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetAlbumsUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import kotlinx.coroutines.flow.Flow - -class GetAlbumsUseCase( - private val repository: MediaRepository -) { - - operator fun invoke( - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending), - ignoreBlacklisted: Boolean = false - ): Flow>> = repository.getAlbums(mediaOrder, ignoreBlacklisted) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetAlbumsWithTypeUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetAlbumsWithTypeUseCase.kt deleted file mode 100644 index b2933f881..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetAlbumsWithTypeUseCase.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.presentation.picker.AllowedMedia -import kotlinx.coroutines.flow.Flow - -class GetAlbumsWithTypeUseCase( - private val repository: MediaRepository -) { - - operator fun invoke( - allowedMedia: AllowedMedia - ): Flow>> = repository.getAlbumsWithType(allowedMedia) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByAlbumUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByAlbumUseCase.kt deleted file mode 100644 index 41a441f94..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByAlbumUseCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -class GetMediaByAlbumUseCase( - private val repository: MediaRepository -) { - operator fun invoke( - albumId: Long, - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending) - ): Flow>> { - return repository.getMediaByAlbumId(albumId).map { - it.apply { - data = data?.let { it1 -> mediaOrder.sortMedia(it1) } - } - } - } - -} - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByAlbumWithTypeUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByAlbumWithTypeUseCase.kt deleted file mode 100644 index 646513dfb..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByAlbumWithTypeUseCase.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import com.dot.gallery.feature_node.presentation.picker.AllowedMedia -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -class GetMediaByAlbumWithTypeUseCase( - private val repository: MediaRepository -) { - operator fun invoke( - albumId: Long, - type: AllowedMedia, - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending) - ): Flow>> { - return repository.getMediaByAlbumIdWithType(albumId, type).map { - it.apply { - data = data?.let { it1 -> mediaOrder.sortMedia(it1) } - } - } - } - -} - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByTypeUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByTypeUseCase.kt deleted file mode 100644 index ef1b77a2e..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByTypeUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.presentation.picker.AllowedMedia -import kotlinx.coroutines.flow.Flow - -class GetMediaByTypeUseCase( - private val repository: MediaRepository -) { - operator fun invoke(type: AllowedMedia): Flow>> = repository.getMediaByType(type) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByUriUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByUriUseCase.kt deleted file mode 100644 index b4baad4c9..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaByUriUseCase.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import kotlinx.coroutines.flow.Flow - -class GetMediaByUriUseCase( - private val repository: MediaRepository -) { - operator fun invoke( - uriAsString: String, - isSecure: Boolean = false - ): Flow>> { - return repository.getMediaByUri(uriAsString, isSecure) - } - -} - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaFavoriteUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaFavoriteUseCase.kt deleted file mode 100644 index 1363721bc..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaFavoriteUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.util.MediaOrder -import com.dot.gallery.feature_node.domain.util.OrderType -import kotlinx.coroutines.flow.Flow - -class GetMediaFavoriteUseCase( - private val repository: MediaRepository -) { - - operator fun invoke( - mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending) - ): Flow>> = repository.getFavorites(mediaOrder) - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaListByUrisUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaListByUrisUseCase.kt deleted file mode 100644 index d6c560bfa..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaListByUrisUseCase.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import android.net.Uri -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import kotlinx.coroutines.flow.Flow - -class GetMediaListByUrisUseCase( - private val repository: MediaRepository -) { - operator fun invoke( - listOfUris: List, - reviewMode: Boolean - ): Flow>> { - return repository.getMediaListByUris(listOfUris, reviewMode) - } - -} - diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaTrashedUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaTrashedUseCase.kt deleted file mode 100644 index d59ae82bb..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaTrashedUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import kotlinx.coroutines.flow.Flow - -class GetMediaTrashedUseCase( - private val repository: MediaRepository -) { - - operator fun invoke(): Flow>> = repository.getTrashed() - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaUseCase.kt deleted file mode 100644 index 220b0a133..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/GetMediaUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.core.Resource -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import kotlinx.coroutines.flow.Flow - -class GetMediaUseCase( - private val repository: MediaRepository -) { - - operator fun invoke(): Flow>> = repository.getMedia() - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/InsertPinnedAlbumUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/InsertPinnedAlbumUseCase.kt deleted file mode 100644 index 051f80696..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/InsertPinnedAlbumUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.model.PinnedAlbum -import com.dot.gallery.feature_node.domain.repository.MediaRepository - -class InsertPinnedAlbumUseCase( - private val repository: MediaRepository -) { - - suspend operator fun invoke( - album: Album - ) = repository.insertPinnedAlbum(PinnedAlbum(album.id)) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaHandleUseCase.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaHandleUseCase.kt index f972048ad..bea07b4b9 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaHandleUseCase.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaHandleUseCase.kt @@ -16,7 +16,7 @@ import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.mediaPair import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext class MediaHandleUseCase( @@ -48,25 +48,22 @@ class MediaHandleUseCase( result: ActivityResultLauncher, mediaList: List, trash: Boolean = true - ) { - withContext(Dispatchers.Default) { - getTrashEnabled(context).collectLatest { isTrashEnabled -> - /** - * Trash media only if user enabled the Trash Can - * Or if user wants to remove existing items from the trash - * */ - if ((isTrashEnabled || !trash)) { - val pair = mediaList.mediaPair() - if (pair.first.isNotEmpty()) { - repository.trashMedia(result, mediaList, trash) - } - if (pair.second.isNotEmpty()) { - repository.deleteMedia(result, mediaList) - } - } else { - repository.deleteMedia(result, mediaList) - } + ) = withContext(Dispatchers.Default) { + val isTrashEnabled = getTrashEnabled(context).firstOrNull() ?: true + /** + * Trash media only if user enabled the Trash Can + * Or if user wants to remove existing items from the trash + * */ + if ((isTrashEnabled || !trash)) { + val pair = mediaList.mediaPair() + if (pair.first.isNotEmpty()) { + repository.trashMedia(result, mediaList, trash) + } + if (pair.second.isNotEmpty()) { + repository.deleteMedia(result, mediaList) } + } else { + repository.deleteMedia(result, mediaList) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaUseCases.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaUseCases.kt deleted file mode 100644 index ad69f415d..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/MediaUseCases.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 IacobIacob01 - * SPDX-License-Identifier: Apache-2.0 - */ - -package com.dot.gallery.feature_node.domain.use_case - -import android.content.Context -import com.dot.gallery.feature_node.domain.repository.MediaRepository - -data class MediaUseCases( - private val context: Context, - private val repository: MediaRepository -) { - val getAlbumsUseCase = GetAlbumsUseCase(repository) - val getAlbumsWithTypeUseCase = GetAlbumsWithTypeUseCase(repository) - val getMediaUseCase = GetMediaUseCase(repository) - val getMediaByAlbumUseCase = GetMediaByAlbumUseCase(repository) - val getMediaByAlbumWithTypeUseCase = GetMediaByAlbumWithTypeUseCase(repository) - val getMediaFavoriteUseCase = GetMediaFavoriteUseCase(repository) - val getMediaTrashedUseCase = GetMediaTrashedUseCase(repository) - val getMediaByTypeUseCase = GetMediaByTypeUseCase(repository) - val getMediaByUriUseCase = GetMediaByUriUseCase(repository) - val getMediaListByUrisUseCase = GetMediaListByUrisUseCase(repository) - val mediaHandleUseCase = MediaHandleUseCase(repository, context) - val insertPinnedAlbumUseCase = InsertPinnedAlbumUseCase(repository) - val deletePinnedAlbumUseCase = DeletePinnedAlbumUseCase(repository) - val blacklistUseCase = BlacklistUseCase(repository) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt deleted file mode 100644 index ced383d98..000000000 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.dot.gallery.feature_node.domain.use_case - -import com.dot.gallery.feature_node.domain.model.EncryptedMedia -import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.repository.MediaRepository -import kotlinx.coroutines.flow.map -import java.io.File - -class VaultUseCases( - private val repository: MediaRepository -) { - - fun getVaults() = repository.getVaults().map { it.data ?: emptyList() } - - suspend fun createVault( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (reason: String) -> Unit - ) = repository.createVault(vault, onSuccess, onFailed) - - suspend fun deleteVault( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (reason: String) -> Unit - ) = repository.deleteVault(vault, onSuccess, onFailed) - - fun getEncryptedMedia(vault: Vault) = - repository.getEncryptedMedia(vault) - - suspend fun addMedia(vault: Vault, media: Media) = - repository.addMedia(vault, media) - - suspend fun restoreMedia(vault: Vault, media: EncryptedMedia) = - repository.restoreMedia(vault, media) - - suspend fun deleteEncryptedMedia(vault: Vault, media: EncryptedMedia) = - repository.deleteEncryptedMedia(vault, media) - - suspend fun deleteAllEncryptedMedia( - vault: Vault, - onSuccess: () -> Unit, - onFailed: (reason: List) -> Unit - ) = repository.deleteAllEncryptedMedia(vault, onSuccess, onFailed) - -} - - 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 index 5df667ae1..ab3a39082 100644 --- 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 @@ -1,13 +1,26 @@ package com.dot.gallery.feature_node.domain.util +import android.net.Uri import androidx.room.TypeConverter import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json object Converters { @TypeConverter - fun fromString(value: String?): List = Json.decodeFromString(value ?: "[]") + fun toString(value: String?): List = Json.decodeFromString(value ?: "[]") @TypeConverter fun fromList(list: List?): String = Json.encodeToString(list ?: emptyList()) + + @TypeConverter + fun toUri(value: String): Uri = Uri.parse(value) + + @TypeConverter + fun fromUri(uri: Uri): String = uri.toString() + + @TypeConverter + fun toMediaOrder(value: String): MediaOrder = Json.decodeFromString(value) + + @TypeConverter + fun fromMediaOrder(mediaOrder: MediaOrder): String = Json.encodeToString(mediaOrder) } \ 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 ec29f5d74..d0cab2214 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 @@ -7,28 +7,29 @@ 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 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -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) +@Serializable +sealed class MediaOrder(open val orderType: OrderType) { + @Serializable + data class Label( + @SerialName("orderType_label") + override val orderType: OrderType + ) : MediaOrder(orderType) - fun flipOrder(): MediaOrder { - return this.copy( - orderType = when (orderType) { - OrderType.Ascending -> OrderType.Descending - OrderType.Descending -> OrderType.Ascending - } - ) - } + @Serializable + data class Date( + @SerialName("orderType_date") + override val orderType: OrderType + ) : MediaOrder(orderType) - fun copy(orderType: OrderType): MediaOrder { - return when (this) { - is Date -> Date(orderType) - is Label -> Label(orderType) - is Expiry -> Expiry(orderType) - } - } + + @Serializable + data class Expiry( + @SerialName("orderType_expiry") + override val orderType: OrderType = OrderType.Descending + ): MediaOrder(orderType) fun sortMedia(media: List): List { return when (orderType) { @@ -69,4 +70,8 @@ sealed class MediaOrder(val orderType: OrderType) { } } } + + companion object { + val Default = Date(OrderType.Descending) + } } \ No newline at end of file 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 85774c577..901bc93c9 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 @@ -5,6 +5,9 @@ package com.dot.gallery.feature_node.presentation.albums +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -24,45 +27,55 @@ import androidx.compose.material3.Text 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.runtime.snapshots.SnapshotStateList 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 import com.dokar.pinchzoomgrid.rememberPinchZoomGridState import com.dot.gallery.R +import com.dot.gallery.feature_node.domain.model.AlbumState +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.albumCellsList +import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize import com.dot.gallery.core.Settings.Album.rememberLastSort -import com.dot.gallery.core.presentation.components.EmptyMedia +import com.dot.gallery.core.presentation.components.EmptyAlbum 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.core.presentation.components.FilterOption +import com.dot.gallery.core.presentation.components.LoadingAlbum +import com.dot.gallery.feature_node.domain.model.Album 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 import com.dot.gallery.feature_node.presentation.search.MainSearchBar import com.dot.gallery.feature_node.presentation.util.Screen +import com.dot.gallery.feature_node.presentation.util.rememberActivityResult @Composable fun AlbumsScreen( navigate: (route: String) -> Unit, - mediaViewModel: MediaViewModel, toggleNavbar: (Boolean) -> Unit, + mediaState: State, + albumsState: State, paddingValues: PaddingValues, - viewModel: AlbumsViewModel, + filterOptions: SnapshotStateList, isScrolling: MutableState, - searchBarActive: MutableState + searchBarActive: MutableState, + onAlbumClick: (Album) -> Unit, + onAlbumLongClick: (Album) -> Unit, + onMoveAlbumToTrash: (ActivityResultLauncher, Album) -> Unit ) { - val mediaState by mediaViewModel.mediaState.collectAsStateWithLifecycle() - val state by viewModel.unPinnedAlbumsState.collectAsStateWithLifecycle() - val pinnedState by viewModel.pinnedAlbumState.collectAsStateWithLifecycle() - val filterOptions = viewModel.rememberFilters() var lastCellIndex by rememberAlbumGridSize() val pinchState = rememberPinchZoomGridState( @@ -85,10 +98,11 @@ fun AlbumsScreen( ) } + var finalPaddingValues by remember(paddingValues) { mutableStateOf(paddingValues) } + Scaffold( topBar = { MainSearchBar( - mediaViewModel = mediaViewModel, bottomPadding = paddingValues.calculateBottomPadding(), navigate = navigate, toggleNavbar = toggleNavbar, @@ -103,7 +117,13 @@ fun AlbumsScreen( } } } - ) { + ) { innerPaddingValues -> + LaunchedEffect(innerPaddingValues) { + finalPaddingValues = PaddingValues( + top = innerPaddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp + ) + } PinchZoomGridLayout(state = pinchState) { LaunchedEffect(gridState.isScrollInProgress) { isScrolling.value = gridState.isScrollInProgress @@ -114,17 +134,18 @@ fun AlbumsScreen( .padding(horizontal = 8.dp) .fillMaxSize(), columns = gridCells, - contentPadding = PaddingValues( - top = it.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp - ), + contentPadding = finalPaddingValues, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - if (pinnedState.albums.isNotEmpty()) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "pinnedAlbums" + item( + span = { GridItemSpan(maxLineSpan) }, + key = "pinnedAlbums" + ) { + AnimatedVisibility( + visible = albumsState.value.albumsPinned.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation ) { Column { Text( @@ -137,17 +158,21 @@ fun AlbumsScreen( fontWeight = FontWeight.Medium ) CarouselPinnedAlbums( - albumList = pinnedState.albums, - onAlbumClick = viewModel.onAlbumClick(navigate), - onAlbumLongClick = viewModel.onAlbumLongClick + albumList = albumsState.value.albumsPinned, + onAlbumClick = onAlbumClick, + onAlbumLongClick = onAlbumLongClick ) } } } - if (state.albums.isNotEmpty()) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "filterButton" + item( + span = { GridItemSpan(maxLineSpan) }, + key = "filterButton" + ) { + AnimatedVisibility( + visible = albumsState.value.albumsUnpinned.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation ) { FilterButton( modifier = Modifier.pinchItem(key = "filterButton"), @@ -156,21 +181,29 @@ fun AlbumsScreen( } } items( - items = state.albums, + items = albumsState.value.albumsUnpinned, key = { item -> item.toString() } ) { item -> + val trashResult = rememberActivityResult() AlbumComponent( modifier = Modifier.pinchItem(key = item.toString()), album = item, - onItemClick = viewModel.onAlbumClick(navigate), - onTogglePinClick = viewModel.onAlbumLongClick + onItemClick = onAlbumClick, + onTogglePinClick = onAlbumLongClick, + onMoveAlbumToTrash = { + onMoveAlbumToTrash(trashResult, it) + } ) } - if (state.albums.isNotEmpty()) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "albumDetails" + item( + span = { GridItemSpan(maxLineSpan) }, + key = "albumDetails" + ) { + AnimatedVisibility( + visible = mediaState.value.media.isNotEmpty() && albumsState.value.albums.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation ) { Text( modifier = Modifier @@ -178,21 +211,52 @@ fun AlbumsScreen( .pinchItem(key = "albumDetails") .padding(horizontal = 8.dp) .padding(vertical = 24.dp), - text = stringResource(R.string.images_videos, mediaState.media.size), + text = stringResource( + R.string.images_videos, + mediaState.value.media.size + ), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center ) } } + + item( + span = { GridItemSpan(maxLineSpan) }, + key = "emptyAlbums" + ) { + AnimatedVisibility( + visible = albumsState.value.albums.isEmpty() && albumsState.value.error.isEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { + EmptyAlbum() + } + } + + item( + span = { GridItemSpan(maxLineSpan) }, + key = "loadingAlbums" + ) { + AnimatedVisibility( + visible = albumsState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + LoadingAlbum() + } + } } } - /** Error State Handling Block **/ - if (state.error.isNotEmpty()) { - Error(errorMessage = state.error) - } else if (state.albums.isEmpty() && pinnedState.albums.isEmpty()) { - EmptyMedia(modifier = Modifier.fillMaxSize()) - } - /** ************ **/ } + /** Error State Handling Block **/ + AnimatedVisibility( + visible = albumsState.value.error.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { + Error(errorMessage = albumsState.value.error) + } + /** ************ **/ } \ No newline at end of file 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 4423b59eb..e76509da9 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 @@ -5,9 +5,9 @@ package com.dot.gallery.feature_node.presentation.albums -import android.annotation.SuppressLint +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember @@ -15,39 +15,35 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.R -import com.dot.gallery.core.AlbumState +import com.dot.gallery.feature_node.domain.model.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 +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.model.TimelineSettings +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.domain.util.OrderType import com.dot.gallery.feature_node.presentation.util.Screen import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class AlbumsViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases + private val repository: MediaRepository, + val handler: MediaHandleUseCase ) : ViewModel() { - 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()) - val pinnedAlbumState = _pinnedAlbumState.asStateFlow() - val handler = mediaUseCases.mediaHandleUseCase - fun onAlbumClick(navigate: (String) -> Unit): (Album) -> Unit = { album -> navigate(Screen.AlbumViewScreen.route + "?albumId=${album.id}&albumName=${album.label}") } @@ -56,11 +52,11 @@ class AlbumsViewModel @Inject constructor( toggleAlbumPin(album, !album.isPinned) } - @SuppressLint("ComposableNaming") - @Composable - fun attachToLifecycle() { - LaunchedEffect(Unit) { - getAlbums() + fun moveAlbumToTrash(result: ActivityResultLauncher, album: Album) { + viewModelScope.launch(Dispatchers.IO) { + val response = repository.getMediaByAlbumId(album.id).firstOrNull() + val data = response?.data ?: emptyList() + repository.trashMedia(result, data, true) } } @@ -72,100 +68,73 @@ class AlbumsViewModel @Inject constructor( FilterOption( titleRes = R.string.filter_type_date, filterKind = FilterKind.DATE, - onClick = { updateOrder(it) } + onClick = { albumOrder = it } ), FilterOption( titleRes = R.string.filter_type_name, filterKind = FilterKind.NAME, - onClick = { updateOrder(it) } + onClick = { albumOrder = it } ) ) } } - private fun updateOrder(mediaOrder: MediaOrder) { - viewModelScope.launch(Dispatchers.IO) { - val newState = unPinnedAlbumsState.value.copy( - albums = mediaOrder.sortAlbums(unPinnedAlbumsState.value.albums) - ) - if (unPinnedAlbumsState.value != newState) { - _unPinnedAlbumsState.emit(newState) - } - } - } - private fun toggleAlbumPin(album: Album, isPinned: Boolean = true) { viewModelScope.launch(Dispatchers.IO) { - val newAlbum = album.copy(isPinned = isPinned) if (isPinned) { - // Insert pinnedAlbumId to Database - mediaUseCases.insertPinnedAlbumUseCase(newAlbum) - // Remove original Album from unpinned List - _unPinnedAlbumsState.emit( - unPinnedAlbumsState.value.copy( - albums = unPinnedAlbumsState.value.albums.minus(album) - ) - ) - // Add 'pinned' version of the album object to the pinned List - _pinnedAlbumState.emit(pinnedAlbumState.value.copy( - albums = pinnedAlbumState.value.albums.toMutableList().apply { add(newAlbum) } - )) + repository.insertPinnedAlbum(PinnedAlbum(album.id)) } else { - // Delete pinnedAlbumId from Database - mediaUseCases.deletePinnedAlbumUseCase(album) - // Add 'un-pinned' version of the album object to the pinned List - _unPinnedAlbumsState.emit(unPinnedAlbumsState.value.copy( - albums = unPinnedAlbumsState.value.albums.toMutableList().apply { add(newAlbum) } - )) - // Remove original Album from pinned List - _pinnedAlbumState.emit( - pinnedAlbumState.value.copy( - albums = pinnedAlbumState.value.albums.minus(album) - ) - ) + repository.removePinnedAlbum(PinnedAlbum(album.id)) } } } - private fun getAlbums(mediaOrder: MediaOrder = MediaOrder.Date(OrderType.Descending)) { - viewModelScope.launch(Dispatchers.IO) { - mediaUseCases.getAlbumsUseCase(mediaOrder).collectLatest { result -> - // Result data list - 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 == albumsState.value.albums) return@collectLatest - val newUnPinnedAlbumState = AlbumState(error = error, albums = data.filter { !it.isPinned }) - val newPinnedState = AlbumState( - error = error, - albums = data.filter { it.isPinned }.sortedBy { it.label }) - if (unPinnedAlbumsState.value != newUnPinnedAlbumState) { - _unPinnedAlbumsState.emit(newUnPinnedAlbumState) - } - if (pinnedAlbumState.value != newPinnedState) { - _pinnedAlbumState.emit(newPinnedState) - } - if (albumsState.value != newAlbumState) { - _albumsState.emit(newAlbumState) - } - if (unfilteredAlbums.value != newAlbumState) { - _unfilteredAlbums.emit(newAlbumState) + private val settingsFlow = repository.getSettings() + .stateIn(viewModelScope, started = SharingStarted.Eagerly, TimelineSettings()) + + private val pinnedAlbums = repository.getPinnedAlbums() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val blacklistedAlbums = repository.getBlacklistedAlbums() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + + private var albumOrder: MediaOrder + get() = settingsFlow.value?.albumMediaOrder ?: MediaOrder.Date(OrderType.Descending) + set(value) { + viewModelScope.launch(Dispatchers.IO) { + settingsFlow.value?.copy(albumMediaOrder = value)?.let { + repository.updateSettings(it) } } } - 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) - } - } + + val albumsFlow = combine( + repository.getAlbums(mediaOrder = albumOrder), + pinnedAlbums, + blacklistedAlbums, + settingsFlow + ) { result, pinnedAlbums, blacklistedAlbums, settings -> + val newOrder = settings?.albumMediaOrder ?: albumOrder + val data = newOrder.sortAlbums(result.data ?: emptyList()) + val cleanData = data.removeBlacklisted(blacklistedAlbums).mapPinned(pinnedAlbums) + + AlbumState( + albums = cleanData, + albumsWithBlacklisted = data, + albumsUnpinned = cleanData.filter { !it.isPinned }, + albumsPinned = cleanData.filter { it.isPinned }.sortedBy { it.label }, + isLoading = false, + error = if (result is Resource.Error) result.message ?: "An error occurred" else "" + ) + }.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(), AlbumState()) + + private fun List.mapPinned(pinnedAlbums: List): List = + map { album -> album.copy(isPinned = pinnedAlbums.any { it.id == album.id }) } + + private fun List.removeBlacklisted(blacklistedAlbums: List): List = + toMutableList().apply { + removeAll { album -> blacklistedAlbums.any { it.matchesAlbum(album) } } } - } } \ No newline at end of file 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 c3fe97e0e..c74023163 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 @@ -44,16 +44,13 @@ import androidx.compose.ui.text.style.TextAlign 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 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.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.feature_node.presentation.util.rememberFeedbackManager import com.dot.gallery.ui.theme.Shapes import com.github.panpf.sketch.AsyncImage import kotlinx.coroutines.launch @@ -64,6 +61,7 @@ fun AlbumComponent( album: Album, isEnabled: Boolean = true, onItemClick: (Album) -> Unit, + onMoveAlbumToTrash: ((Album) -> Unit)? = null, onTogglePinClick: ((Album) -> Unit)? = null, onToggleIgnoreClick: ((Album) -> Unit)? = null ) { @@ -75,16 +73,31 @@ fun AlbumComponent( .padding(horizontal = 8.dp), ) { if (onTogglePinClick != null) { + val trashTitle = stringResource(R.string.move_album_to_trash) val pinTitle = stringResource(R.string.pin) val ignoredTitle = stringResource(id = R.string.add_to_ignored) - val tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer - val onTertiaryContainer = MaterialTheme.colorScheme.onTertiaryContainer + val secondaryContainer = MaterialTheme.colorScheme.secondaryContainer + val onSecondaryContainer = MaterialTheme.colorScheme.onSecondaryContainer + val primaryContainer = MaterialTheme.colorScheme.primaryContainer + val onPrimaryContainer = MaterialTheme.colorScheme.onPrimaryContainer val optionList = remember { mutableListOf( + OptionItem( + text = trashTitle, + containerColor = primaryContainer, + contentColor = onPrimaryContainer, + enabled = onMoveAlbumToTrash != null, + onClick = { + scope.launch { + appBottomSheetState.hide() + onMoveAlbumToTrash?.invoke(album) + } + } + ), OptionItem( text = pinTitle, - containerColor = tertiaryContainer, - contentColor = onTertiaryContainer, + containerColor = secondaryContainer, + contentColor = onSecondaryContainer, onClick = { scope.launch { appBottomSheetState.hide() @@ -143,7 +156,7 @@ fun AlbumComponent( letterSpacing = MaterialTheme.typography.bodyMedium.letterSpacing ) ) { - append(stringResource(R.string.s_items, album.count)) + append(stringResource(R.string.s_items, album.count) + " (${formatSize(album.size)})") } }, textAlign = TextAlign.Center, @@ -182,7 +195,7 @@ fun AlbumComponent( ) } } - AutoResizeText( + Text( modifier = Modifier .padding(top = 12.dp) .padding(horizontal = 16.dp), @@ -190,14 +203,10 @@ fun AlbumComponent( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, - maxLines = 1, - fontSizeRange = FontSizeRange( - min = 10.sp, - max = 16.sp - ) + maxLines = 1 ) if (album.count > 0) { - AutoResizeText( + Text( modifier = Modifier .padding(top = 2.dp, bottom = 16.dp) .padding(horizontal = 16.dp), @@ -209,10 +218,6 @@ fun AlbumComponent( overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.labelMedium, - fontSizeRange = FontSizeRange( - min = 6.sp, - max = 12.sp - ) ) } @@ -241,7 +246,7 @@ fun AlbumImage( .fillMaxSize() .border( width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(cornerRadius) ) .alpha(0.8f) @@ -266,7 +271,7 @@ fun AlbumImage( .fillMaxSize() .border( width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(cornerRadius) ) .clip(RoundedCornerShape(cornerRadius)) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/ChanneledViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/ChanneledViewModel.kt index b32407119..afb7c6448 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/ChanneledViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/ChanneledViewModel.kt @@ -45,6 +45,21 @@ class ChanneledViewModel @Inject constructor() : ViewModel() { } } + fun initWithNav(navController: NavController) = + eventChannel.receiveAsFlow().map { + when (it) { + is Event.NavigationRouteEvent -> + navController.navigate(it.route) { + launchSingleTop = true + restoreState = true + } + is Event.NavigationUpEvent -> + navController.navigateUp() + + else -> {} + } + } + fun navigate(route: String) { viewModelScope.launch(Dispatchers.IO) { eventChannel.send(Event.NavigationRouteEvent(route)) 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 89133844f..fc62199a4 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 @@ -8,7 +8,7 @@ package com.dot.gallery.feature_node.presentation.common import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope @@ -20,6 +20,7 @@ import androidx.compose.material3.rememberTopAppBarState 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 @@ -29,20 +30,15 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dokar.pinchzoomgrid.PinchZoomGridLayout import com.dokar.pinchzoomgrid.rememberPinchZoomGridState -import com.dot.gallery.core.AlbumState -import com.dot.gallery.core.Constants.Animation.enterAnimation -import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.core.Constants.Target.TARGET_TRASH import com.dot.gallery.core.Constants.cellsList -import com.dot.gallery.core.MediaState +import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.core.Settings.Misc.rememberGridSize -import com.dot.gallery.core.presentation.components.Error -import com.dot.gallery.core.presentation.components.LoadingMedia +import com.dot.gallery.core.presentation.components.EmptyMedia import com.dot.gallery.core.presentation.components.NavigationActions import com.dot.gallery.core.presentation.components.NavigationButton import com.dot.gallery.core.presentation.components.SelectionSheet @@ -52,21 +48,19 @@ import com.dot.gallery.feature_node.presentation.common.components.MediaGridView import com.dot.gallery.feature_node.presentation.common.components.TwoLinedDateToolbarTitle import com.dot.gallery.feature_node.presentation.search.MainSearchBar import com.dot.gallery.feature_node.presentation.util.Screen -import kotlinx.coroutines.flow.StateFlow @OptIn( - ExperimentalMaterial3Api::class + ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class ) @Composable fun MediaScreen( paddingValues: PaddingValues, - albumId: Long = -1L, - target: String? = null, + albumId: Long = remember { -1L }, + target: String? = remember { null }, albumName: String, - vm: MediaViewModel, handler: MediaHandleUseCase, - albumState: StateFlow, - mediaState: StateFlow, + albumsState: State, + mediaState: State, selectionState: MutableState, selectedMedia: SnapshotStateList, toggleSelection: (Int) -> Unit, @@ -75,13 +69,13 @@ fun MediaScreen( enableStickyHeaders: Boolean = true, allowNavBar: Boolean = false, navActionsContent: @Composable() (RowScope.(expandedDropDown: MutableState, result: ActivityResultLauncher) -> Unit), - emptyContent: @Composable (PaddingValues) -> Unit, - aboveGridContent: @Composable() (() -> Unit)? = null, + emptyContent: @Composable () -> Unit = { EmptyMedia() }, + aboveGridContent: @Composable() (() -> Unit)? = remember { null }, navigate: (route: String) -> Unit, navigateUp: () -> Unit, toggleNavbar: (Boolean) -> Unit, isScrolling: MutableState = remember { mutableStateOf(false) }, - searchBarActive: MutableState = mutableStateOf(false), + searchBarActive: MutableState = remember { mutableStateOf(false) }, onActivityResult: (result: ActivityResult) -> Unit, ) { val showSearchBar = remember { albumId == -1L && target == null } @@ -103,18 +97,12 @@ fun MediaScreen( lastCellIndex = cellsList.indexOf(pinchState.currentCells) } - /** STATES BLOCK **/ - val state by mediaState.collectAsStateWithLifecycle() - val albumsState by albumState.collectAsStateWithLifecycle() - /** ************ **/ - - /** Selection state handling **/ - LaunchedEffect(LocalConfiguration.current, selectionState.value) { + LaunchedEffect(selectionState.value) { if (allowNavBar) { toggleNavbar(!selectionState.value) } } - /** ************ **/ + Box { Scaffold( modifier = Modifier @@ -129,7 +117,7 @@ fun MediaScreen( title = { TwoLinedDateToolbarTitle( albumName = albumName, - dateHeader = state.dateHeader + dateHeader = mediaState.value.dateHeader ) }, navigationIcon = { @@ -155,11 +143,12 @@ fun MediaScreen( ) } else { MainSearchBar( - mediaViewModel = vm, bottomPadding = paddingValues.calculateBottomPadding(), navigate = navigate, toggleNavbar = toggleNavbar, - selectionState = if (selectedMedia.isNotEmpty()) selectionState else null, + selectionState = remember(selectedMedia) { + if (selectedMedia.isNotEmpty()) selectionState else null + }, isScrolling = isScrolling, activeState = searchBarActive ) { @@ -172,28 +161,20 @@ fun MediaScreen( } ) { it -> PinchZoomGridLayout(state = pinchState) { - AnimatedVisibility( - visible = state.isLoading, - enter = enterAnimation, - exit = exitAnimation - ) { - LoadingMedia( - paddingValues = PaddingValues( - top = it.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp - ) - ) - } MediaGridView( - mediaState = state, + mediaState = mediaState, allowSelection = true, showSearchBar = showSearchBar, - searchBarPaddingTop = paddingValues.calculateTopPadding(), + searchBarPaddingTop = remember(paddingValues) { + paddingValues.calculateTopPadding() + }, enableStickyHeaders = enableStickyHeaders, - paddingValues = PaddingValues( - top = it.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp - ), + paddingValues = remember(paddingValues, it) { + PaddingValues( + top = it.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp + ) + }, canScroll = canScroll, selectionState = selectionState, selectedMedia = selectedMedia, @@ -201,7 +182,8 @@ fun MediaScreen( showMonthlyHeader = showMonthlyHeader, toggleSelection = toggleSelection, aboveGridContent = aboveGridContent, - isScrolling = isScrolling + isScrolling = isScrolling, + emptyContent = emptyContent ) { val albumRoute = "albumId=$albumId" val targetRoute = "target=$target" @@ -209,19 +191,6 @@ fun MediaScreen( if (target != null) targetRoute else albumRoute navigate(Screen.MediaViewScreen.route + "?mediaId=${it.id}&$param") } - /** Error State Handling Block **/ - val showError = remember(state) { state.error.isNotEmpty() } - AnimatedVisibility(visible = showError) { - Error(errorMessage = state.error) - } - val showEmpty = remember(state) { state.media.isEmpty() && !state.isLoading && !showError } - AnimatedVisibility(visible = showEmpty) { - emptyContent.invoke(PaddingValues( - top = it.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() + 16.dp + 64.dp - )) - } - /** ************ **/ } } if (target != TARGET_TRASH) { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt index de6f90038..25cfb8aa3 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt @@ -5,7 +5,6 @@ package com.dot.gallery.feature_node.presentation.common -import android.annotation.SuppressLint import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.compose.runtime.Composable @@ -14,20 +13,29 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.core.MediaState import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaState +import com.dot.gallery.feature_node.domain.model.TimelineSettings import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.use_case.MediaUseCases -import com.dot.gallery.feature_node.domain.use_case.VaultUseCases +import com.dot.gallery.feature_node.domain.model.VaultState +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.presentation.util.collectMedia +import com.dot.gallery.feature_node.presentation.util.mapMediaToItem import com.dot.gallery.feature_node.presentation.util.mediaFlow import com.dot.gallery.feature_node.presentation.util.update import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.xdrop.fuzzywuzzy.FuzzySearch @@ -35,47 +43,96 @@ import javax.inject.Inject @HiltViewModel open class MediaViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases, - private val vaultUseCases: VaultUseCases + private val repository: MediaRepository, + val handler: MediaHandleUseCase, ) : ViewModel() { var lastQuery = mutableStateOf("") - private set val multiSelectState = mutableStateOf(false) - private val _mediaState = MutableStateFlow(MediaState()) - val mediaState = _mediaState.asStateFlow() - private val _customMediaState = MutableStateFlow(MediaState()) - val customMediaState = _customMediaState.asStateFlow() private val _searchMediaState = MutableStateFlow(MediaState()) val searchMediaState = _searchMediaState.asStateFlow() val selectedPhotoState = mutableStateListOf() - val handler = mediaUseCases.mediaHandleUseCase - - private val _vaults = MutableStateFlow>(emptyList()) - val vaults = _vaults.asStateFlow() var albumId: Long = -1L var target: String? = null - var groupByMonth: Boolean = false + var groupByMonth: Boolean + get() = settingsFlow.value?.groupTimelineByMonth ?: false set(value) { - field = value - if (field != value) { - getMedia(albumId, target) + viewModelScope.launch(Dispatchers.IO) { + settingsFlow.value?.copy(groupTimelineByMonth = value)?.let { + repository.updateSettings(it) + } } } - @SuppressLint("ComposableNaming") + private val settingsFlow = repository.getSettings() + .stateIn( + viewModelScope, + started = SharingStarted.Eagerly, + TimelineSettings() + ) + + private val blacklistedAlbums = repository.getBlacklistedAlbums() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val mediaFlow by lazy { + combine( + repository.mediaFlow(albumId, target), + settingsFlow, + blacklistedAlbums, + ) { result, settings, blacklistedAlbums -> + if (result is Resource.Error) return@combine MediaState( + error = result.message ?: "", + isLoading = false + ) + updateDatabase() + mapMediaToItem( + data = (result.data ?: emptyList()).toMutableList().apply { + removeAll { media -> blacklistedAlbums.any { it.matchesMedia(media) } } + }, + error = result.message ?: "", + albumId = albumId, + groupByMonth = settings?.groupTimelineByMonth ?: false, + ) + }.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(), MediaState()) + } + + val vaultsFlow = repository.getVaults() + .map { it.data ?: emptyList() } + .map { VaultState(it, isLoading = false) } + .stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(), VaultState()) + + private sealed class Event { + data object UpdateDatabase : Event() + } + + private val updater = Channel() + @Composable - fun attachToLifecycle() { + fun CollectDatabaseUpdates() { LaunchedEffect(Unit) { - getMedia(albumId, target) + viewModelScope.launch(Dispatchers.IO) { + updater.receiveAsFlow().collectLatest { + when (it) { + is Event.UpdateDatabase -> { + repository.updateInternalDatabase() + } + } + } + } + } + } + + private fun updateDatabase() { + viewModelScope.launch(Dispatchers.IO) { + updater.send(Event.UpdateDatabase) } } fun addMedia(vault: Vault, media: Media) { viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.addMedia(vault, media) + repository.addMedia(vault, media) } } @@ -83,14 +140,6 @@ open class MediaViewModel @Inject constructor( queryMedia("") } - private suspend fun List.parseQuery(query: String): List { - return withContext(Dispatchers.IO) { - if (query.isEmpty()) - return@withContext emptyList() - val matches = FuzzySearch.extractSorted(query, this@parseQuery, { it.toString() }, 60) - return@withContext matches.map { it.referent }.ifEmpty { emptyList() } - } - } fun toggleFavorite( result: ActivityResultLauncher, @@ -102,22 +151,9 @@ open class MediaViewModel @Inject constructor( } } - fun toggleCustomSelection(index: Int) { - viewModelScope.launch(Dispatchers.IO) { - val item = customMediaState.value.media[index] - val selectedPhoto = selectedPhotoState.find { it.id == item.id } - if (selectedPhoto != null) { - selectedPhotoState.remove(selectedPhoto) - } else { - selectedPhotoState.add(item) - } - multiSelectState.update(selectedPhotoState.isNotEmpty()) - } - } - fun toggleSelection(index: Int) { viewModelScope.launch(Dispatchers.IO) { - val item = mediaState.value.media[index] + val item = mediaFlow.value.media[index] val selectedPhoto = selectedPhotoState.find { it.id == item.id } if (selectedPhoto != null) { selectedPhotoState.remove(selectedPhoto) @@ -128,44 +164,6 @@ open class MediaViewModel @Inject constructor( } } - fun getMediaFromAlbum(albumId: Long) { - viewModelScope.launch(Dispatchers.IO) { - val data = mediaState.value.media.filter { it.albumID == albumId } - val error = mediaState.value.error - if (error.isNotEmpty()) { - return@launch _customMediaState.emit(MediaState(isLoading = false, error = error)) - } - if (data.isEmpty()) { - return@launch _customMediaState.emit(MediaState(isLoading = false)) - } - _customMediaState.collectMedia( - data = data, - error = mediaState.value.error, - albumId = albumId, - groupByMonth = groupByMonth - ) - } - } - - fun getFavoriteMedia() { - viewModelScope.launch(Dispatchers.IO) { - val data = mediaState.value.media.filter { it.isFavorite } - val error = mediaState.value.error - if (error.isNotEmpty()) { - return@launch _customMediaState.emit(MediaState(isLoading = false, error = error)) - } - if (data.isEmpty()) { - return@launch _customMediaState.emit(MediaState(isLoading = false)) - } - _customMediaState.collectMedia( - data = data, - error = mediaState.value.error, - albumId = -1L, - groupByMonth = groupByMonth - ) - } - } - fun queryMedia(query: String) { viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.Main) { @@ -176,9 +174,9 @@ open class MediaViewModel @Inject constructor( return@launch } else { _searchMediaState.tryEmit(MediaState(isLoading = true)) - return@launch _searchMediaState.collectMedia( - data = mediaState.value.media.parseQuery(query), - error = mediaState.value.error, + _searchMediaState.collectMedia( + data = mediaFlow.value.media.parseQuery(query), + error = mediaFlow.value.error, albumId = albumId, groupByMonth = groupByMonth ) @@ -186,32 +184,12 @@ open class MediaViewModel @Inject constructor( } } - private fun getMedia(albumId: Long = -1L, target: String? = null) { - viewModelScope.launch(Dispatchers.IO) { - mediaUseCases.mediaFlow(albumId, target).collectLatest { result -> - val data = result.data ?: emptyList() - val error = if (result is Resource.Error) result.message - ?: "An error occurred" else "" - if (error.isNotEmpty()) { - return@collectLatest _mediaState.emit(MediaState(isLoading = false, error = error)) - } - if (data.isEmpty()) { - return@collectLatest _mediaState.emit(MediaState(isLoading = false)) - } - if (data == _mediaState.value.media) return@collectLatest - return@collectLatest _mediaState.collectMedia( - data = data, - error = error, - albumId = albumId, - groupByMonth = groupByMonth, - ) - } - } - viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.getVaults().collectLatest { - _vaults.emit(it) - } + private suspend fun List.parseQuery(query: String): List { + return withContext(Dispatchers.IO) { + if (query.isEmpty()) + return@withContext emptyList() + val matches = FuzzySearch.extractSorted(query, this@parseQuery, { it.toString() }, 60) + return@withContext matches.map { it.referent }.ifEmpty { emptyList() } } } - } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt new file mode 100644 index 000000000..4a4877a03 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/MediaGrid.kt @@ -0,0 +1,346 @@ +package com.dot.gallery.feature_node.presentation.common.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisallowComposableCalls +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +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.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.dokar.pinchzoomgrid.PinchZoomGridScope +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.feature_node.domain.model.MediaState +import com.dot.gallery.core.presentation.components.Error +import com.dot.gallery.core.presentation.components.LoadingMedia +import com.dot.gallery.core.presentation.components.MediaItemHeader +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.domain.model.isBigHeaderKey +import com.dot.gallery.feature_node.domain.model.isHeaderKey +import com.dot.gallery.feature_node.domain.model.isImage +import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.update +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Composable +fun PinchZoomGridScope.MediaGrid( + gridState: LazyGridState, + gridCells: GridCells, + mediaState: State, + mappedData: SnapshotStateList, + paddingValues: PaddingValues, + allowSelection: Boolean, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + toggleSelection: @DisallowComposableCalls (Int) -> Unit, + canScroll: Boolean, + allowHeaders: Boolean, + aboveGridContent: @Composable (() -> Unit)?, + isScrolling: MutableState, + emptyContent: @Composable () -> Unit, + onMediaClick: @DisallowComposableCalls (media: Media) -> Unit +) { + LaunchedEffect(gridState.isScrollInProgress) { + snapshotFlow { + gridState.isScrollInProgress + }.collectLatest { + isScrolling.value = it + } + } + + val topContent: LazyGridScope.() -> Unit = remember(aboveGridContent) { + { + if (aboveGridContent != null) { + item( + span = { GridItemSpan(maxLineSpan) }, + key = "aboveGrid" + ) { + aboveGridContent.invoke() + } + } + } + } + val bottomContent: LazyGridScope.() -> Unit = remember { + { + item( + span = { GridItemSpan(maxLineSpan) }, + key = "loading" + ) { + AnimatedVisibility( + visible = mediaState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + LoadingMedia() + } + } + + item( + span = { GridItemSpan(maxLineSpan) }, + key = "empty" + ) { + AnimatedVisibility( + visible = mediaState.value.media.isEmpty() && !mediaState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + emptyContent() + } + } + item( + span = { GridItemSpan(maxLineSpan) }, + key = "error" + ) { + AnimatedVisibility(visible = mediaState.value.error.isNotEmpty()) { + Error(errorMessage = mediaState.value.error) + } + } + } + } + + AnimatedVisibility( + visible = allowHeaders + ) { + MediaGridContentWithHeaders( + mediaState = mediaState, + mappedData = mappedData, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + onMediaClick = onMediaClick, + topContent = topContent, + bottomContent = bottomContent + ) + } + + AnimatedVisibility( + visible = !allowHeaders + ) { + MediaGridContent( + mediaState = mediaState, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + onMediaClick = onMediaClick, + topContent = topContent, + bottomContent = bottomContent + ) + } + +} + +@Composable +private fun PinchZoomGridScope.MediaGridContentWithHeaders( + mediaState: State, + mappedData: SnapshotStateList, + paddingValues: PaddingValues, + allowSelection: Boolean, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + toggleSelection: @DisallowComposableCalls (Int) -> Unit, + canScroll: Boolean, + onMediaClick: @DisallowComposableCalls (media: Media) -> Unit, + topContent: LazyGridScope.() -> Unit, + bottomContent: LazyGridScope.() -> Unit +) { + val scope = rememberCoroutineScope() + val stringToday = stringResource(id = R.string.header_today) + val stringYesterday = stringResource(id = R.string.header_yesterday) + val feedbackManager = rememberFeedbackManager() + TimelineScroller( + modifier = Modifier + .padding(paddingValues) + .padding(top = 32.dp) + .padding(vertical = 32.dp), + mappedData = mappedData, + headers = remember(mediaState.value) { + mediaState.value.headers.toMutableStateList() + }, + state = gridState, + ) { + LazyVerticalGrid( + state = gridState, + modifier = Modifier.fillMaxSize(), + columns = gridCells, + contentPadding = paddingValues, + userScrollEnabled = canScroll, + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + topContent() + + items( + items = mappedData, + key = { item -> item.key }, + contentType = { item -> item.key.startsWith("media_") }, + span = { item -> + GridItemSpan(if (item.key.isHeaderKey) maxLineSpan else 1) + } + ) { it -> + if (it is MediaItem.Header) { + val isChecked = rememberSaveable { mutableStateOf(false) } + if (allowSelection) { + LaunchedEffect(selectionState.value) { + // Uncheck if selectionState is set to false + isChecked.value = isChecked.value && selectionState.value + } + LaunchedEffect(selectedMedia.size) { + // Partial check of media items should not check the header + isChecked.value = selectedMedia.map { it.id }.containsAll(it.data) + } + } + MediaItemHeader( + modifier = Modifier + .animateItem( + fadeInSpec = null + ) + .pinchItem(key = it.key), + date = remember { + it.text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + }, + showAsBig = remember { it.key.isBigHeaderKey }, + isCheckVisible = selectionState, + isChecked = isChecked + ) { + if (allowSelection) { + feedbackManager.vibrate() + scope.launch { + isChecked.value = !isChecked.value + if (isChecked.value) { + val toAdd = it.data.toMutableList().apply { + // Avoid media from being added twice to selection + removeIf { + selectedMedia.map { media -> media.id }.contains(it) + } + } + selectedMedia.addAll(mediaState.value.media.filter { + toAdd.contains( + it.id + ) + }) + } else selectedMedia.removeAll { media -> it.data.contains(media.id) } + selectionState.update(selectedMedia.isNotEmpty()) + } + } + } + } else if (it is MediaItem.MediaViewItem) { + MediaImage( + modifier = Modifier + .animateItem( + fadeInSpec = null + ) + .pinchItem(key = it.key), + media = it.media, + selectionState = selectionState, + selectedMedia = selectedMedia, + canClick = canScroll, + onItemClick = { + if (selectionState.value && allowSelection) { + feedbackManager.vibrate() + toggleSelection(mediaState.value.media.indexOf(it)) + } else onMediaClick(it) + } + ) { + if (allowSelection) { + feedbackManager.vibrate() + toggleSelection(mediaState.value.media.indexOf(it)) + } + } + } + } + + + bottomContent() + } + } +} + +@Composable +private fun PinchZoomGridScope.MediaGridContent( + mediaState: State, + paddingValues: PaddingValues, + allowSelection: Boolean, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + toggleSelection: @DisallowComposableCalls (Int) -> Unit, + canScroll: Boolean, + onMediaClick: @DisallowComposableCalls (media: Media) -> Unit, + topContent: LazyGridScope.() -> Unit, + bottomContent: LazyGridScope.() -> Unit +) { + val feedbackManager = rememberFeedbackManager() + LazyVerticalGrid( + state = gridState, + modifier = Modifier.fillMaxSize(), + columns = gridCells, + contentPadding = paddingValues, + userScrollEnabled = canScroll, + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + topContent() + + itemsIndexed( + items = mediaState.value.media, + key = { _, item -> item.toString() }, + contentType = { _, item -> item.isImage } + ) { index, media -> + MediaImage( + modifier = Modifier + .animateItem( + fadeInSpec = null + ) + .pinchItem(key = media.toString()), + media = media, + selectionState = selectionState, + selectedMedia = selectedMedia, + canClick = canScroll, + onItemClick = { + if (selectionState.value && allowSelection) { + feedbackManager.vibrate() + toggleSelection(index) + } else onMediaClick(it) + }, + onItemLongClick = { + if (allowSelection) { + feedbackManager.vibrate() + toggleSelection(index) + } + } + ) + } + + bottomContent() + } +} \ No newline at end of file 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 4663f73ca..49d9c7bc9 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 @@ -9,89 +9,88 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets 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.LazyGridItemInfo -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf 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.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.dokar.pinchzoomgrid.PinchZoomGridScope -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.feature_node.domain.model.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 -import com.dot.gallery.feature_node.domain.model.MediaItem -import com.dot.gallery.feature_node.domain.model.isBigHeaderKey import com.dot.gallery.feature_node.domain.model.isHeaderKey import com.dot.gallery.feature_node.domain.model.isIgnoredKey -import com.dot.gallery.feature_node.presentation.util.FeedbackManager -import com.dot.gallery.feature_node.presentation.util.update -import kotlinx.coroutines.launch +import com.dot.gallery.feature_node.presentation.util.roundDpToPx +import com.dot.gallery.feature_node.presentation.util.roundSpToPx +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged -@Stable @OptIn(ExperimentalMaterial3Api::class) @Composable fun PinchZoomGridScope.MediaGridView( - mediaState: MediaState, + mediaState: State, paddingValues: PaddingValues = PaddingValues(0.dp), searchBarPaddingTop: Dp = 0.dp, - showSearchBar: Boolean = false, - allowSelection: Boolean = false, + showSearchBar: Boolean = remember { false }, + allowSelection: Boolean = remember { false }, selectionState: MutableState = remember { mutableStateOf(false) }, selectedMedia: SnapshotStateList = remember { mutableStateListOf() }, - toggleSelection: (Int) -> Unit = {}, + toggleSelection: @DisallowComposableCalls (Int) -> Unit = {}, canScroll: Boolean = true, - allowHeaders: Boolean = remember { true }, + allowHeaders: Boolean = true, enableStickyHeaders: Boolean = false, showMonthlyHeader: Boolean = false, aboveGridContent: @Composable (() -> Unit)? = null, isScrolling: MutableState, - onMediaClick: (media: Media) -> Unit = {} + emptyContent: @Composable () -> Unit, + onMediaClick: @DisallowComposableCalls (media: Media) -> Unit = {}, ) { - val stringToday = stringResource(id = R.string.header_today) - val stringYesterday = stringResource(id = R.string.header_yesterday) + val mappedData by remember(mediaState.value, showMonthlyHeader) { + derivedStateOf { + (if (showMonthlyHeader) mediaState.value.mappedMediaWithMonthly + else mediaState.value.mappedMedia).toMutableStateList() + } + } - val scope = rememberCoroutineScope() - val mappedData = remember(showMonthlyHeader, mediaState) { - if (showMonthlyHeader) mediaState.mappedMediaWithMonthly - else mediaState.mappedMedia + LaunchedEffect(showMonthlyHeader, mediaState.value) { + snapshotFlow { mediaState.value } + .distinctUntilChanged() + .collectLatest { + mappedData.clear() + mappedData.addAll( + if (showMonthlyHeader) mediaState.value.mappedMediaWithMonthly + else mediaState.value.mappedMedia + ) + } } - /** Selection state handling **/ BackHandler( enabled = selectionState.value && allowSelection, onBack = { @@ -99,189 +98,41 @@ fun PinchZoomGridScope.MediaGridView( selectedMedia.clear() } ) - /** ************ **/ - - val feedbackManager = FeedbackManager.rememberFeedbackManager() - @Composable - fun mediaGrid() { - LaunchedEffect(gridState.isScrollInProgress) { - isScrolling.value = gridState.isScrollInProgress - } - Box { - LazyVerticalGrid( - state = gridState, - modifier = Modifier.fillMaxSize(), - columns = gridCells, - contentPadding = paddingValues, - userScrollEnabled = canScroll, - horizontalArrangement = Arrangement.spacedBy(1.dp), - verticalArrangement = Arrangement.spacedBy(1.dp) - ) { - if (aboveGridContent != null) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "aboveGrid" - ) { - aboveGridContent.invoke() - } + /** + * Workaround for a small bug + * That shows the grid at the bottom after content is loaded + */ + val lastLoadingState by remember { mutableStateOf(mediaState.value.isLoading) } + LaunchedEffect(gridState, mediaState.value) { + snapshotFlow { mediaState.value.isLoading } + .distinctUntilChanged() + .collectLatest { isLoading -> + if (!isLoading && lastLoadingState) { + gridState.scrollToItem(0) } - - if (allowHeaders) { - items( - items = mappedData, - key = { item -> item.key }, - contentType = { item -> item.key.startsWith("media_") }, - span = { item -> - GridItemSpan(if (item.key.isHeaderKey) maxLineSpan else 1) - } - ) { it -> - val item = remember { - if (it is MediaItem.Header) it - else it as MediaItem.MediaViewItem - } - if (item is MediaItem.Header) { - val isChecked = rememberSaveable { mutableStateOf(false) } - if (allowSelection) { - LaunchedEffect(selectionState.value) { - // Uncheck if selectionState is set to false - isChecked.value = isChecked.value && selectionState.value - } - LaunchedEffect(selectedMedia.size) { - // Partial check of media items should not check the header - isChecked.value = selectedMedia.containsAll(item.data) - } - } - StickyHeader( - modifier = Modifier.pinchItem(key = it.key), - date = remember { - item.text - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - }, - showAsBig = remember { item.key.isBigHeaderKey }, - isCheckVisible = selectionState, - isChecked = isChecked - ) { - if (allowSelection) { - feedbackManager.vibrate() - scope.launch { - isChecked.value = !isChecked.value - if (isChecked.value) { - val toAdd = item.data.toMutableList().apply { - // Avoid media from being added twice to selection - removeIf { selectedMedia.contains(it) } - } - selectedMedia.addAll(toAdd) - } else selectedMedia.removeAll(item.data) - selectionState.update(selectedMedia.isNotEmpty()) - } - } - } - } else { - MediaImage( - modifier = Modifier - .animateItem() - .pinchItem(key = it.key), - media = (item as MediaItem.MediaViewItem).media, - selectionState = selectionState, - selectedMedia = selectedMedia, - canClick = canScroll, - onItemClick = { - if (selectionState.value && allowSelection) { - feedbackManager.vibrate() - toggleSelection(mediaState.media.indexOf(it)) - } else onMediaClick(it) - } - ) { - if (allowSelection) { - feedbackManager.vibrate() - toggleSelection(mediaState.media.indexOf(it)) - } - } - } - } - } else { - itemsIndexed( - items = mediaState.media, - key = { _, item -> item.toString() }, - contentType = { _, item -> item.isImage } - ) { index, media -> - MediaImage( - modifier = Modifier - .animateItem() - .pinchItem(key = media.toString()), - media = media, - selectionState = selectionState, - selectedMedia = selectedMedia, - canClick = canScroll, - onItemClick = { - if (selectionState.value && allowSelection) { - feedbackManager.vibrate() - toggleSelection(index) - } else onMediaClick(it) - }, - onItemLongClick = { - if (allowSelection) { - feedbackManager.vibrate() - toggleSelection(index) - } - } - ) - } - } - } - - if (allowHeaders) { - TimelineScroller( - gridState = gridState, - mappedData = mappedData, - paddingValues = paddingValues - ) } - } } - if (enableStickyHeaders) { - /** - * Remember last known header item - */ - val stickyHeaderLastItem = remember { mutableStateOf(null) } - - val headers = remember(mappedData) { - mappedData.filterIsInstance().filter { !it.key.isBigHeaderKey } - } - - val stickyHeaderItem by remember(mappedData) { - derivedStateOf { - val firstItem = gridState.layoutInfo.visibleItemsInfo.firstOrNull() - val firstHeaderIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull { - it.key.isHeaderKey && !it.key.toString().contains("big") - }?.index - - val item = firstHeaderIndex?.let(mappedData::getOrNull) - stickyHeaderLastItem.apply { - if (item != null && item is MediaItem.Header) { - val newItem = item.text - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - val newIndex = (headers.indexOf(item) - 1).coerceAtLeast(0) - val previousHeader = headers[newIndex].text - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - value = if (firstItem != null && !firstItem.key.isHeaderKey) { - previousHeader - } else { - newItem - } - } - }.value - } - } + AnimatedVisibility( + visible = enableStickyHeaders + ) { + val stickyHeaderItem by rememberStickyHeaderItem( + gridState = gridState, + headers = remember(mediaState.value) { + mediaState.value.headers.toMutableStateList() + }, + mappedData = mappedData + ) val hideSearchBarSetting by rememberAutoHideSearchBar() val searchBarPadding by animateDpAsState( - targetValue = remember(isScrolling.value, showSearchBar, searchBarPaddingTop, hideSearchBarSetting) { + 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 @@ -290,25 +141,26 @@ fun PinchZoomGridScope.MediaGridView( ) val density = LocalDensity.current - val searchBarOffset = remember(density, showSearchBar, searchBarPadding) { - with(density) { - return@with if (showSearchBar) - 28.sp.roundToPx() + searchBarPadding.roundToPx() else 0 - } - } - val headerMatcher: (LazyGridItemInfo) -> Boolean = remember { - { item -> item.key.isHeaderKey || item.key.isIgnoredKey } + val searchBarHeightPx = WindowInsets.statusBars.getTop(density) + val searchBarPaddingPx by remember(density, searchBarPadding) { + derivedStateOf { with(density) { searchBarPadding.roundToPx() } } } - val headerOffset by rememberHeaderOffset(gridState, headerMatcher, searchBarOffset) + StickyHeaderGrid( + state = gridState, modifier = Modifier.fillMaxSize(), - showSearchBar = showSearchBar, - headerOffset = headerOffset, + headerMatcher = { item -> item.key.isHeaderKey || item.key.isIgnoredKey }, + searchBarOffset = { if (showSearchBar) 28.roundSpToPx(density) + searchBarPaddingPx else 0 }, + toolbarOffset = { if (showSearchBar) 0 else 64.roundDpToPx(density) + searchBarHeightPx }, stickyHeader = { - val show = remember( - mediaState, + val show by remember( + mediaState.value, stickyHeaderItem - ) { mediaState.media.isNotEmpty() && stickyHeaderItem != null } + ) { + derivedStateOf { + mediaState.value.media.isNotEmpty() && stickyHeaderItem != null + } + } AnimatedVisibility( visible = show, enter = enterAnimation, @@ -339,8 +191,46 @@ fun PinchZoomGridScope.MediaGridView( ) } } - ) { mediaGrid() } - } else mediaGrid() - + ) { + MediaGrid( + gridState = gridState, + gridCells = gridCells, + mediaState = mediaState, + mappedData = mappedData, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + allowHeaders = allowHeaders, + aboveGridContent = aboveGridContent, + isScrolling = isScrolling, + emptyContent = emptyContent, + onMediaClick = onMediaClick + ) + } + } + AnimatedVisibility( + visible = !enableStickyHeaders + ) { + MediaGrid( + gridState = gridState, + gridCells = gridCells, + mediaState = mediaState, + mappedData = mappedData, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + allowHeaders = allowHeaders, + aboveGridContent = aboveGridContent, + isScrolling = isScrolling, + emptyContent = emptyContent, + onMediaClick = onMediaClick + ) + } } \ No newline at end of file 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 c526933ac..34906ad01 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,7 +27,6 @@ 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 @@ -44,12 +43,13 @@ import androidx.compose.ui.unit.dp 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.isFavorite +import com.dot.gallery.feature_node.domain.model.isVideo 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( @@ -131,9 +131,7 @@ fun MediaImage( } AnimatedVisibility( - visible = remember(media) { - media.duration != null - }, + visible = remember(media) { media.isVideo }, enter = Animation.enterAnimation, exit = Animation.exitAnimation, modifier = Modifier.align(Alignment.TopEnd) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt index 27c023178..b4f639b93 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/TimelineScroller.kt @@ -1,217 +1,143 @@ package com.dot.gallery.feature_node.presentation.common.components -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.boundsInParent -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex 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.feature_node.domain.model.MediaItem -import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.math.abs +import com.dot.gallery.feature_node.presentation.util.getDate +import my.nanihadesuka.compose.InternalLazyVerticalGridScrollbar +import my.nanihadesuka.compose.ScrollbarLayoutSide +import my.nanihadesuka.compose.ScrollbarSelectionActionable +import my.nanihadesuka.compose.ScrollbarSelectionMode +import my.nanihadesuka.compose.ScrollbarSettings @Composable -fun BoxScope.TimelineScroller( - gridState: LazyGridState, - mappedData: List, - paddingValues: PaddingValues -) { - val scope = rememberCoroutineScope() - val offsets = remember { mutableStateMapOf() } - var selectedIndex by remember { mutableIntStateOf(0) } - val feedbackManager = rememberFeedbackManager() - fun scrollIfNeeded(offset: Float) { - val index = offsets - .mapValues { abs(it.value - offset) } - .entries - .minByOrNull { it.value } - ?.key ?: return - if (selectedIndex == index) return - selectedIndex = index - scope.launch { - feedbackManager.vibrate() - gridState.scrollToItem(selectedIndex, scrollOffset = -250) - } - } - - var scrollVisible by remember { mutableStateOf(false) } - val scrollAlpha by animateFloatAsState( - animationSpec = tween(durationMillis = 1000), - targetValue = if (scrollVisible) 1f else 0f, - label = "scrollAlpha" - ) - - val configuration = LocalConfiguration.current - val height = remember(configuration) { configuration.screenHeightDp } - val headerList = - remember(mappedData) { mappedData.filterIsInstance() } - val bottomPadding = remember { 32 /*dp*/ } - - /** - * Distribute scroll items height proportionally to the amount of date headers - **/ - val parentTopPadding = remember(paddingValues) { paddingValues.calculateTopPadding().value } - val parentBottomPadding = remember { paddingValues.calculateBottomPadding().value } - val heightSize = - remember(height, parentTopPadding, parentBottomPadding, bottomPadding, headerList) { - (height - parentTopPadding - parentBottomPadding - bottomPadding) / headerList.size - } - - // W.I.P. - /*val yearList = remember { mutableStateListOf() } - LazyColumn( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(paddingValues) - .padding(top = 32.dp, bottom = bottomPadding.dp) - .padding(end = 48.dp) - .fillMaxHeight() - .wrapContentWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.End - ) { - itemsIndexed( - items = headerList, - key = { _, item -> item.key } - ) { index, item -> - val year = getYear(item.text) - if (!yearList.contains(year)) { - yearList.add(year) - val padding = heightSize * index - Log.d( - Constants.TAG, - "Height size: $heightSize, Index: $index, Top padding for $year: $padding" - ) - Text( - modifier = Modifier - .graphicsLayer { - translationY = -padding - } - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(100) - ) - .padding(vertical = 4.dp, horizontal = 5.dp), - text = year, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - }*/ - - Box( - modifier = Modifier - .padding(paddingValues) - .padding(top = 32.dp, bottom = bottomPadding.dp) - .graphicsLayer { - translationY = offsets[selectedIndex] ?: 0f - alpha = scrollAlpha - } - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.tertiary, - shape = RoundedCornerShape( - topStartPercent = 100, - bottomStartPercent = 100 - ) - ) - .align(Alignment.TopEnd) - .padding(vertical = 2.5.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.ic_scroll_arrow), - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiary +fun rememberScrollbarSettings( + headers: SnapshotStateList, +) : ScrollbarSettings { + val enabled by remember(headers) { derivedStateOf { headers.size > 3 } } + return remember(headers, enabled) { + ScrollbarSettings.Default.copy( + enabled = enabled, + side = ScrollbarLayoutSide.End, + selectionMode = ScrollbarSelectionMode.Thumb, + selectionActionable = ScrollbarSelectionActionable.WhenVisible, + scrollbarPadding = 0.dp, + thumbThickness = 24.dp, + thumbUnselectedColor = Color.Transparent, + thumbSelectedColor = Color.Transparent, + hideDisplacement = 0.dp ) } +} - Column( - modifier = Modifier - .padding(paddingValues) - .padding(top = 32.dp, bottom = bottomPadding.dp) - .align(Alignment.TopEnd) - .fillMaxHeight() - .verticalScroll(state = rememberScrollState()) - .graphicsLayer { - alpha = scrollAlpha - } - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragStart = { - scope.launch { - scrollVisible = true - } - }, - onDragCancel = { - scope.launch { - delay(1500) - scrollVisible = false - } - }, - onDragEnd = { - scope.launch { - delay(500) - scrollVisible = false - } - }, - onVerticalDrag = { change, _ -> - if (!scrollVisible) { - scrollVisible = true +@Composable +fun TimelineScroller( + state: LazyGridState, + modifier: Modifier = Modifier, + mappedData: SnapshotStateList, + headers: SnapshotStateList, + settings: ScrollbarSettings = rememberScrollbarSettings(headers), + content: @Composable () -> Unit +) { + if (!settings.enabled) content() + else Box { + content() + InternalLazyVerticalGridScrollbar( + state = state, + settings = settings, + modifier = modifier, + indicatorContent = { index, isSelected -> + val stringToday = stringResource(R.string.header_today) + val stringYesterday = stringResource(R.string.header_yesterday) + val currentDate by remember(mappedData, index) { + derivedStateOf { + mappedData.getOrNull((index + 1).coerceAtMost(mappedData.size - 1))?.let { item -> + when (item) { + is MediaItem.MediaViewItem -> item.media.timestamp.getDate( + stringToday = stringToday, + stringYesterday = stringYesterday + ) + is MediaItem.Header -> item.text + } } - scrollIfNeeded(change.position.y) } + } + val isScrolling by remember(state) { derivedStateOf { state.isScrollInProgress } } + val offset by animateDpAsState( + targetValue = if (isScrolling || isSelected) 24.dp else 72.dp, label = "thumbOffset" ) - }, - verticalArrangement = Arrangement.Top - ) { - mappedData.forEachIndexed { i, header -> - if (header is MediaItem.Header) { - Spacer( - modifier = Modifier - .width(16.dp) - .height(heightSize.dp) - .onGloballyPositioned { - offsets[i] = it.boundsInParent().center.y - } - ) + Row( + modifier = Modifier.offset { + IntOffset(offset.roundToPx(), 0) + }.zIndex(5f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + androidx.compose.animation.AnimatedVisibility( + visible = !currentDate.isNullOrEmpty() && isSelected, + enter = enterAnimation(250), + exit = exitAnimation(1000) + ) { + Text( + text = currentDate.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(100) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + Box( + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.tertiary, + shape = RoundedCornerShape( + topStartPercent = 100, + bottomStartPercent = 100 + ) + ) + .padding(vertical = 2.5.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_scroll_arrow), + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiary + ) + } + } } - } + ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt index 91d8ab396..9de964f01 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberHeaderOffset.kt @@ -1,32 +1,134 @@ package com.dot.gallery.feature_node.presentation.common.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateIntOffsetAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.IntOffset @Composable -fun rememberHeaderOffset( - lazyGridState: LazyGridState, - headerMatcher: (LazyGridItemInfo) -> Boolean, - searchBarOffset: Int -): State { - return remember { +inline fun StickyHeaderGrid( + state: LazyGridState, + modifier: Modifier, + crossinline headerMatcher: @DisallowComposableCalls (LazyGridItemInfo) -> Boolean, + crossinline searchBarOffset: @DisallowComposableCalls () -> Int, + crossinline toolbarOffset: @DisallowComposableCalls () -> Int, + stickyHeader: @Composable () -> Unit, + content: @Composable () -> Unit +) { + StickyHeaderLayout( + lazyState = state, + modifier = modifier, + viewportStart = { state.layoutInfo.viewportStartOffset }, + lazyItems = { state.layoutInfo.visibleItemsInfo }, + lazyItemOffset = { offset.y }, + lazyItemHeight = { size.height }, + headerMatcher = headerMatcher, + stickyHeader = stickyHeader, + searchBarOffset = searchBarOffset, + toolbarOffset = toolbarOffset, + content = content, + ) +} + +@Composable +inline fun StickyHeaderStaggeredGrid( + state: LazyStaggeredGridState, + modifier: Modifier, + crossinline headerMatcher: @DisallowComposableCalls (LazyStaggeredGridItemInfo) -> Boolean, + crossinline searchBarOffset: @DisallowComposableCalls () -> Int, + crossinline toolbarOffset: @DisallowComposableCalls () -> Int, + stickyHeader: @Composable () -> Unit, + content: @Composable () -> Unit +) { + StickyHeaderLayout( + lazyState = state, + modifier = modifier, + viewportStart = { state.layoutInfo.viewportStartOffset }, + lazyItems = { state.layoutInfo.visibleItemsInfo }, + lazyItemOffset = { offset.y }, + lazyItemHeight = { size.height }, + headerMatcher = headerMatcher, + stickyHeader = stickyHeader, + searchBarOffset = searchBarOffset, + toolbarOffset = toolbarOffset, + content = content, + ) +} + +@Composable +inline fun StickyHeaderLayout( + lazyState: LazyState, + modifier: Modifier = Modifier, + crossinline viewportStart: @DisallowComposableCalls (LazyState) -> Int, + crossinline lazyItems: @DisallowComposableCalls (LazyState) -> List, + crossinline lazyItemOffset: @DisallowComposableCalls LazyItem.() -> Int, + crossinline lazyItemHeight: @DisallowComposableCalls LazyItem.() -> Int, + crossinline searchBarOffset: @DisallowComposableCalls () -> Int, + crossinline toolbarOffset: @DisallowComposableCalls () -> Int, + crossinline headerMatcher: @DisallowComposableCalls LazyItem.() -> Boolean, + stickyHeader: @Composable () -> Unit, + content: @Composable () -> Unit +) { + val headerOffset by remember(lazyState) { derivedStateOf { - val layoutInfo = lazyGridState.layoutInfo - val startOffset = layoutInfo.viewportStartOffset - val firstCompletelyVisibleItem = layoutInfo.visibleItemsInfo.firstOrNull { - it.offset.y >= startOffset - } - when (firstCompletelyVisibleItem?.let { headerMatcher(it) }) { - true -> firstCompletelyVisibleItem.size - .height - .minus(firstCompletelyVisibleItem.offset.y) - .let { difference -> if (difference < 0) 0 else -difference - searchBarOffset } - else -> 0 + val searchBarOffsetValue = searchBarOffset() + val startOffset = viewportStart(lazyState) + val visibleItems = lazyItems(lazyState) + val firstCompletelyVisibleItem = visibleItems.firstOrNull { lazyItem -> + lazyItemOffset(lazyItem) >= startOffset + } ?: return@derivedStateOf 0 + + when (headerMatcher(firstCompletelyVisibleItem)) { + false -> 0 + true -> lazyItemHeight(firstCompletelyVisibleItem) + .minus(lazyItemOffset(firstCompletelyVisibleItem)) + .let { difference -> if (difference < 0) 0 else -difference - searchBarOffsetValue} } } } + val toolbarOffsetValue by remember(headerOffset, toolbarOffset()) { + derivedStateOf { + toolbarOffset() + } + } + + val offsetAnimation by animateIntOffsetAsState( + remember(headerOffset, toolbarOffsetValue) { + IntOffset( + x = 0, + y = headerOffset + toolbarOffsetValue + ) + }, label = "offsetAnimation" + ) + + val alphaAnimation by animateFloatAsState( + targetValue = remember(offsetAnimation) { if (offsetAnimation.y < -100) 0f else 1f }, + label = "alphaAnimation", + animationSpec = tween(100, 10), + ) + + Box(modifier = modifier) { + content() + Box( + modifier = Modifier + .alpha(alphaAnimation) + .offset { offsetAnimation } + ) { + stickyHeader() + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt new file mode 100644 index 000000000..8419f0b49 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/components/rememberStickyHeaderItem.kt @@ -0,0 +1,58 @@ +package com.dot.gallery.feature_node.presentation.common.components + +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.res.stringResource +import com.dot.gallery.R +import com.dot.gallery.feature_node.domain.model.MediaItem +import com.dot.gallery.feature_node.domain.model.isHeaderKey + +@Composable +fun rememberStickyHeaderItem( + gridState: LazyGridState, + headers: SnapshotStateList, + mappedData: SnapshotStateList +): State { + val stringToday = stringResource(id = R.string.header_today) + val stringYesterday = stringResource(id = R.string.header_yesterday) + + /** + * Remember last known header item + */ + val stickyHeaderLastItem = remember { mutableStateOf(null) } + + LaunchedEffect(gridState, headers, mappedData) { + snapshotFlow { gridState.layoutInfo.visibleItemsInfo } + .collect { visibleItems -> + val firstItem = visibleItems.firstOrNull() + val firstHeaderIndex = visibleItems.firstOrNull { + it.key.isHeaderKey && !it.key.toString().contains("big") + }?.index + + val item = firstHeaderIndex?.let(mappedData::getOrNull) + stickyHeaderLastItem.value = if (item != null && item is MediaItem.Header) { + val newItem = item.text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + val newIndex = (headers.indexOf(item) - 1).coerceAtLeast(0) + val previousHeader = headers[newIndex].text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + if (firstItem != null && !firstItem.key.isHeaderKey) { + previousHeader + } else { + newItem + } + } else { + stickyHeaderLastItem.value + } + } + } + return stickyHeaderLastItem +} \ No newline at end of file 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 59f700bde..6842f7e62 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 @@ -18,7 +18,8 @@ import androidx.lifecycle.viewModelScope 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 -import com.dot.gallery.feature_node.domain.use_case.MediaUseCases +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase 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 @@ -40,7 +41,8 @@ import javax.inject.Inject @HiltViewModel class EditViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases, + private val repository: MediaRepository, + private val mediaUseCases: MediaHandleUseCase, @ApplicationContext private val applicationContext: Context ) : ViewModel() { @@ -104,7 +106,7 @@ class EditViewModel @Inject constructor( fun loadImage(uri: Uri) { currentUri = uri viewModelScope.launch(Dispatchers.IO) { - mediaUseCases.getMediaListByUrisUseCase(listOf(uri), reviewMode = false) + repository.getMediaListByUris(listOf(uri), reviewMode = false) .collectLatest { val data = it.data ?: emptyList() _image.emit(null) @@ -179,7 +181,7 @@ class EditViewModel @Inject constructor( ) { viewModelScope.launch(Dispatchers.IO) { val done = if (asCopy) { - mediaUseCases.mediaHandleUseCase.saveImage( + mediaUseCases.saveImage( bitmap = image.asAndroidBitmap(), format = bitmapFormat, relativePath = mediaRef.value?.relativePath @@ -189,7 +191,7 @@ class EditViewModel @Inject constructor( mimeType = mediaRef.value?.mimeType ?: "image/png" ) != null } else { - mediaUseCases.mediaHandleUseCase.overrideImage( + mediaUseCases.overrideImage( uri = mediaRef.value!!.uri, bitmap = image.asAndroidBitmap(), format = bitmapFormat, 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 41b6efb41..52e249e75 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 @@ -38,13 +38,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.SecureFlagPolicy import com.dot.gallery.R -import com.dot.gallery.core.AlbumState +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.core.Constants import com.dot.gallery.core.Constants.albumCellsList 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.Media +import com.dot.gallery.feature_node.domain.model.volume import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState 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 02157d00d..22ed8f706 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 @@ -1,8 +1,6 @@ package com.dot.gallery.feature_node.presentation.exif import android.media.MediaScannerConnection -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,20 +10,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.DeleteSweep -import androidx.compose.material.icons.outlined.GpsOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -42,21 +31,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -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 com.dot.gallery.R import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.feature_node.domain.model.ExifAttributes import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.isImage +import com.dot.gallery.feature_node.domain.model.isVideo import com.dot.gallery.feature_node.domain.model.rememberExifAttributes import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberExifInterface -import com.dot.gallery.feature_node.presentation.util.rememberExifMetadata import com.dot.gallery.feature_node.presentation.util.toastError import com.dot.gallery.feature_node.presentation.util.writeRequest import com.dot.gallery.ui.theme.Shapes @@ -119,104 +106,16 @@ fun MetadataEditSheet( .verticalScroll(rememberScrollState()) ) { Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.onSurface, - fontStyle = MaterialTheme.typography.titleLarge.fontStyle, - fontSize = MaterialTheme.typography.titleLarge.fontSize, - letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing - ) - ) { - append(stringResource(R.string.edit_metadata)) - } - append("\n") - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontStyle = MaterialTheme.typography.bodyMedium.fontStyle, - fontSize = MaterialTheme.typography.bodyMedium.fontSize, - letterSpacing = MaterialTheme.typography.bodyMedium.letterSpacing - ) - ) { - append(stringResource(R.string.beta)) - } - }, + text = if (media.isVideo) stringResource(R.string.update_file_name) + else stringResource(R.string.edit_metadata), textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, modifier = Modifier .padding(24.dp) .fillMaxWidth() ) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(state = rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(Modifier.width(8.dp)) - FilterChip( - selected = shouldRemoveMetadata, - onClick = { - shouldRemoveLocation = false - shouldRemoveMetadata = !shouldRemoveMetadata - }, - label = { - Text(text = stringResource(R.string.remove_metadata)) - }, - leadingIcon = { - Icon( - modifier = Modifier.size(18.dp), - imageVector = Icons.Outlined.DeleteSweep, - contentDescription = null - ) - }, - shape = RoundedCornerShape(100), - border = null, - colors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer, - iconColor = MaterialTheme.colorScheme.onTertiaryContainer, - selectedContainerColor = MaterialTheme.colorScheme.tertiary, - selectedLabelColor = MaterialTheme.colorScheme.onTertiary, - selectedLeadingIconColor = MaterialTheme.colorScheme.onTertiary - ) - ) - val exifMetadata = exifInterface?.let { rememberExifMetadata(media, it) } - AnimatedVisibility(visible = exifMetadata?.gpsLatLong != null) { - FilterChip( - selected = shouldRemoveLocation, - onClick = { - shouldRemoveMetadata = false - shouldRemoveLocation = !shouldRemoveLocation - }, - label = { - Text(text = stringResource(R.string.remove_location)) - }, - leadingIcon = { - Icon( - modifier = Modifier.size(18.dp), - imageVector = Icons.Outlined.GpsOff, - contentDescription = null - ) - }, - enabled = exifMetadata?.gpsLatLong != null, - shape = RoundedCornerShape(100), - border = null, - colors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer, - iconColor = MaterialTheme.colorScheme.onPrimaryContainer, - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary, - selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimary - ) - ) - } - Spacer(Modifier.width(8.dp)) - } - TextField( modifier = Modifier .padding(horizontal = 16.dp) @@ -238,26 +137,30 @@ fun MetadataEditSheet( ) ) - TextField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .height(112.dp), - value = exifAttributes.imageDescription ?: "", - onValueChange = { newValue -> - exifAttributes = exifAttributes.copy(imageDescription = newValue) - }, - label = { - Text(text = stringResource(R.string.description)) - }, - shape = Shapes.large, - colors = TextFieldDefaults.colors( - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent + androidx.compose.animation.AnimatedVisibility( + visible = media.isImage + ) { + TextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .height(112.dp), + value = exifAttributes.imageDescription ?: "", + onValueChange = { newValue -> + exifAttributes = exifAttributes.copy(imageDescription = newValue) + }, + label = { + Text(text = stringResource(R.string.description)) + }, + shape = Shapes.large, + colors = TextFieldDefaults.colors( + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ) ) - ) + } Row( modifier = Modifier.padding(24.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 4026debda..fc3046f78 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -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 @@ -38,13 +37,14 @@ 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.AlbumState +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.core.Constants import com.dot.gallery.core.Constants.albumCellsList 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.Media +import com.dot.gallery.feature_node.domain.model.volume import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.presentation.albums.components.AlbumComponent import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt index 05a68e13b..f9cb19c60 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/FavoriteScreen.kt @@ -9,32 +9,28 @@ import android.app.Activity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.core.AlbumState +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.core.Constants.Target.TARGET_FAVORITES -import com.dot.gallery.core.MediaState +import com.dot.gallery.feature_node.domain.model.MediaState 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.favorites.components.EmptyFavorites import com.dot.gallery.feature_node.presentation.favorites.components.FavoriteNavActions -import kotlinx.coroutines.flow.StateFlow @Composable fun FavoriteScreen( paddingValues: PaddingValues, albumName: String = stringResource(id = R.string.favorites), - vm: MediaViewModel, handler: MediaHandleUseCase, - mediaState: StateFlow, - albumState: StateFlow, + mediaState: State, + albumsState: State, selectionState: MutableState, selectedMedia: SnapshotStateList, toggleFavorite: (ActivityResultLauncher, List, Boolean) -> Unit, @@ -46,18 +42,17 @@ fun FavoriteScreen( paddingValues = paddingValues, target = TARGET_FAVORITES, albumName = albumName, - vm = vm, handler = handler, + albumsState = albumsState, mediaState = mediaState, - albumState = albumState, selectionState = selectionState, selectedMedia = selectedMedia, toggleSelection = toggleSelection, navActionsContent = { _: MutableState, result: ActivityResultLauncher -> - FavoriteNavActions(toggleFavorite, mediaState, selectedMedia, selectionState, result) + FavoriteNavActions(toggleFavorite, mediaState.value, selectedMedia, selectionState, result) }, - emptyContent = { EmptyFavorites(Modifier.fillMaxSize()) }, + emptyContent = { EmptyFavorites() }, navigate = navigate, navigateUp = navigateUp, toggleNavbar = toggleNavbar diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt index c92e87cd4..25b557f8c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/favorites/components/FavoriteNavActions.kt @@ -7,24 +7,24 @@ package com.dot.gallery.feature_node.presentation.favorites.components import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest +import androidx.compose.animation.AnimatedVisibility import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R -import com.dot.gallery.core.MediaState +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.domain.model.Media -import kotlinx.coroutines.flow.StateFlow @Composable fun FavoriteNavActions( toggleFavorite: (ActivityResultLauncher, List, Boolean) -> Unit, - mediaState: StateFlow, + mediaState: MediaState, selectedMedia: SnapshotStateList, selectionState: MutableState, result: ActivityResultLauncher @@ -32,11 +32,14 @@ fun FavoriteNavActions( val removeAllTitle = stringResource(R.string.remove_all) val removeSelectedTitle = stringResource(R.string.remove_selected) val title = if (selectionState.value) removeSelectedTitle else removeAllTitle - val state by mediaState.collectAsStateWithLifecycle() - if (state.media.isNotEmpty()) { + AnimatedVisibility( + visible = mediaState.media.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { TextButton( onClick = { - toggleFavorite(result, selectedMedia.ifEmpty { state.media }, false) + toggleFavorite(result, selectedMedia.ifEmpty { mediaState.media }, false) } ) { Text( 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 2428e14d1..d6059747f 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 @@ -30,6 +30,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,7 +46,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R -import com.dot.gallery.core.AlbumState +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.core.Position import com.dot.gallery.core.SettingsEntity import com.dot.gallery.feature_node.domain.model.IgnoredAlbum @@ -58,10 +59,9 @@ import com.dot.gallery.ui.core.Icons as GalleryIcons fun IgnoredScreen( navigateUp: () -> Unit, startSetup: () -> Unit, - albumsState: AlbumState, + albumsState: State, ) { val vm = hiltViewModel() - vm.attachToLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val state by vm.blacklistState.collectAsStateWithLifecycle(IgnoredState()) 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 c283480a7..e71ec0111 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 @@ -1,51 +1,28 @@ package com.dot.gallery.feature_node.presentation.ignored -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 com.dot.gallery.feature_node.domain.repository.MediaRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class IgnoredViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases + private val repository: MediaRepository ): ViewModel() { - private val _ignoredState = MutableStateFlow(IgnoredState()) - val blacklistState = _ignoredState.asStateFlow() - - init { - getIgnoredAlbums() - } - - @SuppressLint("ComposableNaming") - @Composable - fun attachToLifecycle() { - LaunchedEffect(Unit) { - getIgnoredAlbums() - } - } + val blacklistState = repository.getBlacklistedAlbums() + .map { IgnoredState(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), IgnoredState()) fun removeFromBlacklist(ignoredAlbum: IgnoredAlbum) { viewModelScope.launch { - mediaUseCases.blacklistUseCase.removeFromBlacklist(ignoredAlbum) - } - } - - private fun getIgnoredAlbums() { - viewModelScope.launch { - mediaUseCases.blacklistUseCase.blacklistedAlbums.collectLatest { - _ignoredState.emit(IgnoredState(it)) - } + repository.removeBlacklistedAlbum(ignoredAlbum) } } } \ No newline at end of file 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 index 20290a46d..968c2bbf7 100644 --- 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 @@ -3,6 +3,7 @@ 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.State import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -10,7 +11,7 @@ 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.feature_node.domain.model.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 @@ -25,11 +26,9 @@ import java.util.UUID @Composable fun IgnoredSetup( onCancel: () -> Unit, - albumState: AlbumState, + albumState: State, ) { - val vm = hiltViewModel().apply { - attachToLifecycle() - } + val vm = hiltViewModel() val navController = rememberNavController() val uiState by vm.uiState.collectAsStateWithLifecycle() @@ -124,7 +123,7 @@ fun IgnoredSetup( 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) + val matchedAlbums = albumState.value.albumsWithBlacklisted.filter(regex::matchesAlbum) vm.setMatchedAlbums(matchedAlbums) } catch (e: Exception) { e.printStackTrace() 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 index 08159c79e..0f1188747 100644 --- 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 @@ -1,8 +1,5 @@ package com.dot.gallery.feature_node.presentation.ignored.setup -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,19 +7,20 @@ 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.domain.repository.MediaRepository 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.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class IgnoredSetupViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases + private val repository: MediaRepository ): ViewModel() { private val _uiState = MutableStateFlow(IgnoredSetupState()) @@ -30,20 +28,9 @@ class IgnoredSetupViewModel @Inject constructor( var isLabelError by mutableStateOf(false) - private val _ignoredState = MutableStateFlow(IgnoredState()) - val blacklistState = _ignoredState.asStateFlow() - - init { - getIgnoredAlbums() - } - - @SuppressLint("ComposableNaming") - @Composable - fun attachToLifecycle() { - LaunchedEffect(Unit) { - getIgnoredAlbums() - } - } + val blacklistState = repository.getBlacklistedAlbums() + .map { IgnoredState(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), IgnoredState()) fun setLabel(label: String) { _uiState.value = _uiState.value.copy(label = label) @@ -68,15 +55,8 @@ class IgnoredSetupViewModel @Inject constructor( fun addToIgnored(ignoredAlbum: IgnoredAlbum) { viewModelScope.launch { - mediaUseCases.blacklistUseCase.addToBlacklist(ignoredAlbum) + repository.addBlacklistedAlbum(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/SelectAlbumSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/SelectAlbumSheet.kt index 8db4e6e4b..d3a128a7f 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/SelectAlbumSheet.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/ignored/setup/SelectAlbumSheet.kt @@ -25,11 +25,11 @@ 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.AlbumState 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.AlbumState 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 @@ -43,6 +43,7 @@ fun SelectAlbumSheet( albumState: AlbumState, onSelect: (Album) -> Unit ) { + val albumSize by rememberAlbumGridSize() val scope = rememberCoroutineScope() if (sheetState.isVisible) { ModalBottomSheet( @@ -71,7 +72,6 @@ fun SelectAlbumSheet( .fillMaxWidth() ) - val albumSize by rememberAlbumGridSize() LazyVerticalGrid( state = rememberLazyGridState(), modifier = Modifier.padding(horizontal = 8.dp), 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 index 8bee761cb..8066654a7 100644 --- 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 @@ -14,6 +14,7 @@ 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 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.Album @@ -32,6 +33,7 @@ fun SetupConfirmationScreen( title = stringResource(R.string.setup_confirmation_title), subtitle = stringResource(R.string.setup_confirmation_subtitle), icon = Icons.Outlined.Checklist, + contentPadding = 0.dp, bottomBar = { OutlinedButton( onClick = onGoBack 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 index 7067c9330..79afb5964 100644 --- 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 @@ -41,6 +41,7 @@ fun SetupLabelScreen( title = stringResource(R.string.setup_label_title), subtitle = stringResource(R.string.setup_lavel_subtitle), icon = Icons.Outlined.VisibilityOff, + contentPadding = 0.dp, bottomBar = { OutlinedButton( onClick = onGoBack 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 index 4c8802094..f2face82b 100644 --- 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 @@ -53,6 +53,7 @@ fun SetupLocationScreen( title = stringResource(R.string.setup_location_title), subtitle = stringResource(R.string.setup_location_subtitle), icon = Icons.Outlined.Settings, + contentPadding = 0.dp, bottomBar = { OutlinedButton( onClick = onGoBack 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 index 3a6b41a15..9f3377a4f 100644 --- 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 @@ -64,6 +64,7 @@ fun SetupTypeRegexScreen( title = stringResource(R.string.setup_type_regex_title), subtitle = stringResource(R.string.setup_type_regex_subtitle), icon = Icons.RegularExpression, + contentPadding = 0.dp, bottomBar = { OutlinedButton( onClick = onGoBack 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 index 120239cd1..bde5daaaf 100644 --- 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 @@ -27,6 +27,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -54,9 +55,9 @@ 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.AlbumState 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 @@ -69,7 +70,7 @@ fun SetupTypeSelectionScreen( onNext: () -> Unit, initialAlbum: Album?, ignoredAlbums: List, - albumsState: AlbumState, + albumsState: State, onAlbumChanged: (Album?) -> Unit, ) { var album by remember { mutableStateOf(initialAlbum) } @@ -81,6 +82,7 @@ fun SetupTypeSelectionScreen( title = stringResource(R.string.setup_type_selection_title), subtitle = stringResource(R.string.setup_type_selection_subtitle), icon = Icons.Outlined.PhotoAlbum, + contentPadding = 0.dp, bottomBar = { OutlinedButton( onClick = onGoBack @@ -205,7 +207,7 @@ fun SetupTypeSelectionScreen( SelectAlbumSheet( sheetState = pickAlbumState, ignoredAlbums = ignoredAlbums, - albumState = albumsState, + albumState = albumsState.value, ) { pickedAlbum -> album = pickedAlbum onAlbumChanged(pickedAlbum) 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 948e40357..90f18bb04 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 @@ -1,15 +1,13 @@ package com.dot.gallery.feature_node.presentation.library -import android.os.Build +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.icons.Icons @@ -17,8 +15,6 @@ import androidx.compose.material.icons.outlined.DeleteOutline 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 import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -37,25 +33,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dokar.pinchzoomgrid.PinchZoomGridLayout import com.dokar.pinchzoomgrid.rememberPinchZoomGridState import com.dot.gallery.R import com.dot.gallery.core.Constants.albumCellsList import com.dot.gallery.core.Settings.Album.rememberAlbumGridSize -import com.dot.gallery.feature_node.presentation.common.MediaViewModel +import com.dot.gallery.feature_node.presentation.library.components.LibrarySmallItem import com.dot.gallery.feature_node.presentation.search.MainSearchBar import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.ui.core.icons.Encrypted +import com.dot.gallery.ui.core.Icons as GalleryIcons @Composable fun LibraryScreen( navigate: (route: String) -> Unit, - mediaViewModel: MediaViewModel, toggleNavbar: (Boolean) -> Unit, paddingValues: PaddingValues, isScrolling: MutableState, searchBarActive: MutableState ) { + val viewModel = hiltViewModel() var lastCellIndex by rememberAlbumGridSize() val pinchState = rememberPinchZoomGridState( @@ -67,10 +66,11 @@ fun LibraryScreen( lastCellIndex = albumCellsList.indexOf(pinchState.currentCells) } + val indicatorState by viewModel.indicatorState.collectAsStateWithLifecycle() + Scaffold( topBar = { MainSearchBar( - mediaViewModel = mediaViewModel, bottomPadding = paddingValues.calculateBottomPadding(), navigate = navigate, toggleNavbar = toggleNavbar, @@ -124,91 +124,68 @@ fun LibraryScreen( modifier = Modifier .fillMaxWidth() .pinchItem(key = "headerButtons") - .padding(horizontal = 8.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 16.dp) + .padding(top = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally + ) ) { - Button( - modifier = Modifier.weight(1f), - onClick = { - navigate(Screen.TrashedScreen.route) - }, - colors = ButtonDefaults.filledTonalButtonColors() - ) { - Icon( - imageVector = Icons.Outlined.DeleteOutline, - contentDescription = stringResource(id = R.string.trash) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(id = R.string.trash) - ) - } - Button( - modifier = Modifier.weight(1f), - onClick = { - navigate(Screen.FavoriteScreen.route) - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary - ) - ) { - Icon( - imageVector = Icons.Outlined.FavoriteBorder, - contentDescription = stringResource(id = R.string.favorites) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(id = R.string.favorites) - ) - } + LibrarySmallItem( + title = stringResource(R.string.trash), + icon = Icons.Outlined.DeleteOutline, + contentColor = MaterialTheme.colorScheme.primary, + useIndicator = true, + indicatorCounter = indicatorState.trashCount, + modifier = Modifier + .weight(1f) + .clickable { + navigate(Screen.TrashedScreen.route) + } + ) + LibrarySmallItem( + title = stringResource(R.string.favorites), + icon = Icons.Outlined.FavoriteBorder, + contentColor = MaterialTheme.colorScheme.error, + useIndicator = true, + indicatorCounter = indicatorState.favoriteCount, + modifier = Modifier + .weight(1f) + .clickable { + navigate(Screen.FavoriteScreen.route) + } + ) } Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally + ) ) { - Button( - modifier = Modifier.weight(1f), - onClick = { - navigate(Screen.VaultScreen()) - }, - colors = ButtonDefaults.filledTonalButtonColors(), - enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - ) { - Icon( - imageVector = com.dot.gallery.ui.core.Icons.Encrypted, - contentDescription = stringResource(id = R.string.vault) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(R.string.vault) - ) - } - Button( - modifier = Modifier.weight(1f), - onClick = { - navigate(Screen.IgnoredScreen()) - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary - ) - ) { - Icon( - imageVector = Icons.Outlined.VisibilityOff, - contentDescription = stringResource(R.string.ignored_albums) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(id = R.string.ignored_albums) - ) - } + LibrarySmallItem( + title = stringResource(R.string.vault), + icon = GalleryIcons.Encrypted, + contentColor = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .weight(1f) + .clickable { + navigate(Screen.VaultScreen()) + }, + contentDescription = stringResource(R.string.vault) + ) + LibrarySmallItem( + title = stringResource(R.string.ignored), + icon = Icons.Outlined.VisibilityOff, + contentColor = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .weight(1f) + .clickable { + navigate(Screen.IgnoredScreen()) + } + ) } } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt new file mode 100644 index 000000000..8a66041de --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/LibraryViewModel.kt @@ -0,0 +1,30 @@ +package com.dot.gallery.feature_node.presentation.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dot.gallery.feature_node.domain.model.LibraryIndicatorState +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.util.MediaOrder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class LibraryViewModel @Inject constructor( + repository: MediaRepository, +) : ViewModel() { + + val indicatorState = combine( + repository.getTrashed(), + repository.getFavorites(MediaOrder.Default) + ) { + trashed, favorites -> + LibraryIndicatorState( + trashCount = trashed.data?.size ?: 0, + favoriteCount = favorites.data?.size ?: 0 + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LibraryIndicatorState()) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/components/LibrarySmallItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/components/LibrarySmallItem.kt new file mode 100644 index 000000000..a783fbf56 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/library/components/LibrarySmallItem.kt @@ -0,0 +1,79 @@ +package com.dot.gallery.feature_node.presentation.library.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation + +@Composable +fun LibrarySmallItem( + modifier: Modifier = Modifier, + title: String, + icon: ImageVector, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + useIndicator: Boolean = false, + indicatorCounter: Int = 0, + contentDescription: String = title, +) { + ListItem( + colors = ListItemDefaults.colors( + containerColor = contentColor.copy(alpha = 0.1f), + headlineColor = contentColor + ), + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .then(modifier), + headlineContent = { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + }, + leadingContent = { + Icon( + imageVector = icon, + tint = contentColor, + contentDescription = contentDescription + ) + }, + trailingContent = { + AnimatedVisibility( + useIndicator && indicatorCounter > 0, + enter = enterAnimation, + exit = exitAnimation + ) { + Text( + text = remember(indicatorCounter) { + indicatorCounter.coerceAtMost(99) + .toString() + if (indicatorCounter > 99) "+" else "" + }, + style = MaterialTheme.typography.bodySmall, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(end = 4.dp) + ) + } + } + ) +} \ No newline at end of file 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 f316084df..bdf409ea2 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 @@ -15,8 +15,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -41,7 +39,6 @@ import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : AppCompatActivity() { - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) @@ -50,7 +47,6 @@ class MainActivity : AppCompatActivity() { enableEdgeToEdge() setContent { GalleryTheme { - val windowSizeClass = calculateWindowSizeClass(this) val navController = rememberNavController() val isScrolling = remember { mutableStateOf(false) } val bottomBarState = rememberSaveable { mutableStateOf(true) } @@ -80,7 +76,6 @@ class MainActivity : AppCompatActivity() { navController = navController, paddingValues = paddingValues, bottomBarState = bottomBarState.value, - windowSizeClass = windowSizeClass, isScrolling = isScrolling.value ) { NavigationComp( 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 d6effad18..83a1f9069 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 @@ -8,26 +8,35 @@ package com.dot.gallery.feature_node.presentation.mediaview import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState 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.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -36,33 +45,45 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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 com.dot.gallery.core.AlbumState -import com.dot.gallery.core.Constants +import com.composables.core.BottomSheet +import com.composables.core.SheetDetent.Companion.FullyExpanded +import com.composables.core.rememberBottomSheetState import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.DEFAULT_LOW_VELOCITY_SWIPE_DURATION +import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION import com.dot.gallery.core.Constants.HEADER_DATE_FORMAT import com.dot.gallery.core.Constants.Target.TARGET_TRASH -import com.dot.gallery.core.MediaState +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.model.VaultState +import com.dot.gallery.feature_node.domain.model.readUriOnly import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase +import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewActions2 import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewAppBar -import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewBottomBar +import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewDetails import com.dot.gallery.feature_node.presentation.mediaview.components.media.MediaPreviewComponent import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayerController -import com.dot.gallery.feature_node.presentation.trashed.components.TrashedViewBottomBar +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.BOTTOM_BAR_HEIGHT +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.BOTTOM_BAR_HEIGHT_SLIM +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.FullyExpanded +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.ImageOnly 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.normalize import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController import com.dot.gallery.feature_node.presentation.util.toggleSystemBars +import com.dot.gallery.ui.theme.BlackScrim import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @Stable @@ -75,75 +96,116 @@ fun MediaViewScreen( isStandalone: Boolean = false, mediaId: Long, target: String? = null, - mediaState: MediaState, - albumsState: AlbumState, + mediaState: State, + albumsState: State, handler: MediaHandleUseCase, - vaults: List, + vaultState: State, addMedia: (Vault, Media) -> Unit ) { - var runtimeMediaId by rememberSaveable(mediaId) { mutableLongStateOf(mediaId) } - val initialPage = rememberSaveable(runtimeMediaId) { - mediaState.media.indexOfFirst { it.id == runtimeMediaId }.coerceAtLeast(0) + var initialPage by rememberSaveable(mediaId, mediaState.value) { + val lastMediaPosition = mediaState.value.media.indexOfFirst { it.id == mediaId } + mutableIntStateOf(if (lastMediaPosition != -1) lastMediaPosition else 0) } val pagerState = rememberPagerState( initialPage = initialPage, initialPageOffsetFraction = 0f, - pageCount = mediaState.media::size + pageCount = mediaState.value.media::size ) - val bottomSheetState = rememberAppBottomSheetState() + LaunchedEffect(mediaState.value) { + snapshotFlow { mediaState.value.isLoading } + .collectLatest { isLoading -> + if (!isLoading) { + initialPage = mediaState.value.media.indexOfFirst { it.id == mediaId } + pagerState.scrollToPage(initialPage) + } + } + } val currentDate = rememberSaveable { mutableStateOf("") } val currentMedia = rememberSaveable { mutableStateOf(null) } + val isReadOnly by remember(currentMedia.value) { + derivedStateOf { currentMedia.value?.readUriOnly == true } + } + val scope = rememberCoroutineScope() val showUI = rememberSaveable { mutableStateOf(true) } val windowInsetsController = rememberWindowInsetsController() var lastIndex by remember { mutableIntStateOf(-1) } - 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 showInfo by remember(currentMedia.value) { + derivedStateOf { currentMedia.value?.trashed == 0 && !isReadOnly } } - val scope = rememberCoroutineScope() - val showInfo = remember(currentMedia.value) { - currentMedia.value?.trashed == 0 && !(currentMedia.value?.readUriOnly ?: false) + BackHandler(!showUI.value) { + windowInsetsController.toggleSystemBars(show = true) + navigateUp() + } + var sheetHeightDp by remember { mutableStateOf(0.dp) } + val sheetState = rememberBottomSheetState( + initialDetent = ImageOnly, + detents = listOf(ImageOnly, FullyExpanded { sheetHeightDp = it }) + ) + + var normalizationTarget by remember { + mutableFloatStateOf(0f) + } + var isNormalizationTargetSet by remember { mutableStateOf(false) } + var lastPage by remember { mutableIntStateOf(pagerState.currentPage) } + + LaunchedEffect(mediaState.value) { + snapshotFlow { pagerState.currentPage }.collectLatest { page -> + if (lastIndex != -1) { + val newIndex = lastIndex.coerceAtMost(pagerState.pageCount - 1) + pagerState.scrollToPage(newIndex) + lastIndex = -1 + } + if (page != lastPage) { + isNormalizationTargetSet = false + } + + currentMedia.value = mediaState.value.media.getOrNull(page) + currentDate.value = currentMedia.value?.timestamp?.getDate(HEADER_DATE_FORMAT) ?: "" + + if (!mediaState.value.isLoading && mediaState.value.media.isEmpty() && !isStandalone) { + windowInsetsController.toggleSystemBars(show = true) + navigateUp() + } + lastPage = page + } } - LaunchedEffect(pagerState, mediaState.media) { - snapshotFlow { pagerState.currentPage }.collect { page -> - updateContent(page) + LaunchedEffect(isNormalizationTargetSet) { + snapshotFlow { sheetState.offset }.collectLatest { offset -> + if (offset < 1f && !isNormalizationTargetSet) { + isNormalizationTargetSet = true + normalizationTarget = offset + } } } - BackHandler(!showUI.value) { - windowInsetsController.toggleSystemBars(show = true) - navigateUp() + val normalizedOffset by remember(normalizationTarget) { + derivedStateOf { + if (isNormalizationTargetSet) { + sheetState.offset.normalize(minValue = normalizationTarget) + } else 0f + } + } + val bottomPadding = remember { + paddingValues.calculateBottomPadding() } Box( modifier = Modifier - .background(Color.Black) .fillMaxSize() + .background(Color.Black) ) { HorizontalPager( modifier = Modifier - .pointerInput(showInfo) { - detectVerticalDragGestures { change, dragAmount -> - if (showInfo && dragAmount < -5) { - change.consume() - scope.launch { - bottomSheetState.show() - } - } - } + .fillMaxSize() + .graphicsLayer { + translationY = + -((sheetHeightDp - BOTTOM_BAR_HEIGHT - bottomPadding).toPx() * normalizedOffset) }, state = pagerState, flingBehavior = PagerDefaults.flingBehavior( @@ -154,159 +216,223 @@ fun MediaViewScreen( ) ), key = { index -> - if (mediaState.media.isNotEmpty()) { - mediaState.media[index.coerceIn(0 until mediaState.media.size)].id - } else "empty" + mediaState.value.media.getOrNull(index) ?: "empty" }, pageSpacing = 16.dp, ) { index -> - var playWhenReady by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.currentPage } - .collect { currentPage -> - playWhenReady = currentPage == index - } - } - val media = remember(index, mediaState) { - mediaState.media[index] - } - - MediaPreviewComponent( - media = media, - uiEnabled = showUI.value, - playWhenReady = playWhenReady, - onItemClick = { - showUI.value = !showUI.value - windowInsetsController.toggleSystemBars(showUI.value) + Column( + modifier = Modifier.fillMaxWidth() + ) { + var playWhenReady by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { currentPage -> + playWhenReady = currentPage == index + } + } + val media by remember(index, mediaState) { + derivedStateOf { mediaState.value.media.getOrNull(index) } } - ) { player, isPlaying, currentTime, totalTime, buffer, frameRate -> - Box( - modifier = Modifier.fillMaxSize() - ) { - val displayMetrics = LocalContext.current.resources.displayMetrics - //Width And Height Of Screen - val width = displayMetrics.widthPixels - Spacer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = width / 1.5f + AnimatedVisibility( + visible = remember(media) { media != null }, + enter = enterAnimation, + exit = exitAnimation + ) { + MediaPreviewComponent( + media = media!!, + uiEnabled = showUI.value, + playWhenReady = playWhenReady, + onSwipeDown = navigateUp, + onItemClick = { + if (sheetState.currentDetent == ImageOnly) { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars(showUI.value) } - .align(Alignment.TopEnd) - .clip(CircleShape) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onDoubleClick = { - scope.launch { - currentTime.longValue += 10 * 1000 - player.seekTo(currentTime.longValue) - delay(100) - player.play() + } + ) { player, isPlaying, currentTime, totalTime, buffer, frameRate -> + Box( + modifier = Modifier.fillMaxSize() + ) { + val context = LocalContext.current + val width = + remember(context) { context.resources.displayMetrics.widthPixels } + Spacer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = width / 1.5f } - }, - onClick = { - showUI.value = !showUI.value - windowInsetsController.toggleSystemBars(showUI.value) - } + .align(Alignment.TopEnd) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onDoubleClick = { + scope.launch { + currentTime.longValue += 10 * 1000 + player.seekTo(currentTime.longValue) + delay(100) + player.play() + } + }, + onClick = { + if (sheetState.currentDetent == ImageOnly) { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars( + showUI.value + ) + } + } + ) ) - ) - Spacer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = -width / 1.5f - } - .align(Alignment.TopStart) - .clip(CircleShape) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onDoubleClick = { - scope.launch { - currentTime.longValue -= 10 * 1000 - player.seekTo(currentTime.longValue) - delay(100) - player.play() + Spacer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = -width / 1.5f } - }, - onClick = { - showUI.value = !showUI.value - windowInsetsController.toggleSystemBars(showUI.value) - } + .align(Alignment.TopStart) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onDoubleClick = { + scope.launch { + currentTime.longValue -= 10 * 1000 + player.seekTo(currentTime.longValue) + delay(100) + player.play() + } + }, + onClick = { + if (sheetState.currentDetent == ImageOnly) { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars( + showUI.value + ) + } + } + ) ) - ) - AnimatedVisibility( - visible = showUI.value, - enter = enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), - exit = exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), - modifier = Modifier.fillMaxSize() - ) { - VideoPlayerController( - paddingValues = paddingValues, - player = player, - isPlaying = isPlaying, - currentTime = currentTime, - totalTime = totalTime, - buffer = buffer, - toggleRotate = toggleRotate, - frameRate = frameRate - ) + androidx.compose.animation.AnimatedVisibility( + visible = showUI.value, + enter = enterAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), + exit = exitAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), + modifier = Modifier.fillMaxSize() + ) { + VideoPlayerController( + paddingValues = paddingValues, + player = player, + isPlaying = isPlaying, + currentTime = currentTime, + totalTime = totalTime, + buffer = buffer, + toggleRotate = toggleRotate, + frameRate = frameRate + ) + } + } } } + } } MediaViewAppBar( showUI = showUI.value, showInfo = showInfo, - showDate = remember(currentMedia) { + showDate = remember(currentMedia.value) { currentMedia.value?.timestamp != 0L }, currentDate = currentDate.value, - bottomSheetState = bottomSheetState, paddingValues = paddingValues, - onGoBack = navigateUp + onShowInfo = { + scope.launch { + sheetState.animateTo(FullyExpanded) + } + }, + onGoBack = { + scope.launch { + if (sheetState.currentDetent == FullyExpanded) { + sheetState.animateTo(ImageOnly) + } else { + navigateUp() + } + } + } ) - AnimatedVisibility( - modifier = Modifier.align(Alignment.BottomCenter), - visible = target == TARGET_TRASH, - enter = enterAnimation, - exit = exitAnimation - ) { - TrashedViewBottomBar( - handler = handler, - showUI = showUI.value, - paddingValues = paddingValues, - currentMedia = currentMedia.value, - currentIndex = pagerState.currentPage - ) { - lastIndex = it + BackHandler(sheetState.currentDetent == FullyExpanded) { + scope.launch { + sheetState.animateTo(ImageOnly) } } - AnimatedVisibility( - modifier = Modifier.align(Alignment.BottomCenter), - visible = target != TARGET_TRASH, - enter = enterAnimation, - exit = exitAnimation + val bottomSheetAlpha by animateFloatAsState( + targetValue = if (showUI.value) 1f else 0f, + label = "MediaViewActionsAlpha" + ) + BottomSheet( + state = sheetState, + enabled = showUI.value && target != TARGET_TRASH, + modifier = Modifier + .align(Alignment.BottomCenter) + .alpha(bottomSheetAlpha) + .fillMaxWidth() ) { - MediaViewBottomBar( - showDeleteButton = remember(currentMedia.value) { - currentMedia.value?.readUriOnly == false - }, - bottomSheetState = bottomSheetState, - handler = handler, - showUI = showUI.value, - paddingValues = paddingValues, - currentMedia = currentMedia.value, - albumsState = albumsState, - currentIndex = pagerState.currentPage, - addMedia = addMedia, - vaults = vaults + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - lastIndex = it + val alpha by animateFloatAsState( + targetValue = 1f - normalizedOffset, + label = "MediaViewActions2Alpha" + ) + AnimatedVisibility( + visible = remember(currentMedia.value) { + currentMedia.value != null + }, + enter = enterAnimation, + exit = exitAnimation + ) { + Row( + modifier = Modifier + .graphicsLayer { + this.alpha = alpha + translationY = + BOTTOM_BAR_HEIGHT_SLIM.toPx() * normalizedOffset + } + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, BlackScrim) + ) + ) + .padding( + top = 24.dp, + bottom = bottomPadding + ) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + MediaViewActions2( + currentIndex = pagerState.currentPage, + currentMedia = currentMedia.value!!, + handler = handler, + onDeleteMedia = { lastIndex = it }, + showDeleteButton = !isReadOnly + ) + } + } + + MediaViewDetails( + handler = handler, + albumsState = albumsState, + currentMedia = currentMedia.value, + vaultState = vaultState, + addMediaToVault = addMedia + ) } } } 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 fd464e797..457a5b77c 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,9 +21,6 @@ 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 import androidx.compose.ui.graphics.Brush @@ -35,12 +32,8 @@ 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, @@ -49,9 +42,8 @@ fun MediaViewAppBar( currentDate: String, paddingValues: PaddingValues, onGoBack: () -> Unit, - bottomSheetState: AppBottomSheetState, + onShowInfo: () -> Unit ) { - val scope = rememberCoroutineScope() AnimatedVisibility( visible = showUI, enter = enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), @@ -98,16 +90,12 @@ fun MediaViewAppBar( ) } AnimatedVisibility( - visible = showUI, + visible = showInfo, enter = enterAnimation, exit = exitAnimation ) { IconButton( - onClick = { - scope.launch { - bottomSheetState.show() - } - } + onClick = onShowInfo ) { Image( imageVector = Icons.Outlined.Info, 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 b8f3b9e80..f8e4113bd 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 @@ -5,7 +5,10 @@ package com.dot.gallery.feature_node.presentation.mediaview.components -import androidx.activity.compose.BackHandler +import android.content.Intent +import android.provider.MediaStore +import android.widget.Toast +import androidx.activity.result.IntentSenderRequest import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -16,24 +19,19 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.DriveFileMove import androidx.compose.material.icons.automirrored.outlined.OpenInNew @@ -42,16 +40,18 @@ import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.GpsOff +import androidx.compose.material.icons.outlined.LocalFireDepartment import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.RestoreFromTrash import androidx.compose.material.icons.outlined.Share -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,315 +61,437 @@ 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.toUpperCase +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.dot.gallery.BuildConfig import com.dot.gallery.R -import com.dot.gallery.core.AlbumState -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.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION import com.dot.gallery.core.presentation.components.DragHandle +import com.dot.gallery.core.presentation.components.NavigationBarSpacer +import com.dot.gallery.feature_node.domain.model.AlbumState +import com.dot.gallery.feature_node.domain.model.ExifAttributes +import com.dot.gallery.feature_node.domain.model.LocationData import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaDateCaption import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.model.VaultState +import com.dot.gallery.feature_node.domain.model.fileExtension +import com.dot.gallery.feature_node.domain.model.isFavorite +import com.dot.gallery.feature_node.domain.model.isRaw +import com.dot.gallery.feature_node.domain.model.isTrashed +import com.dot.gallery.feature_node.domain.model.isVideo +import com.dot.gallery.feature_node.domain.model.readUriOnly +import com.dot.gallery.feature_node.domain.model.rememberExifAttributes +import com.dot.gallery.feature_node.domain.model.rememberLocationData +import com.dot.gallery.feature_node.domain.model.rememberMediaDateCaption import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import com.dot.gallery.feature_node.presentation.exif.CopyMediaSheet import com.dot.gallery.feature_node.presentation.exif.MetadataEditSheet import com.dot.gallery.feature_node.presentation.exif.MoveMediaSheet import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialog import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.ExifMetadata import com.dot.gallery.feature_node.presentation.util.MapBoxURL import com.dot.gallery.feature_node.presentation.util.connectivityState -import com.dot.gallery.feature_node.presentation.util.formattedAddress -import com.dot.gallery.feature_node.presentation.util.getDate -import com.dot.gallery.feature_node.presentation.util.getLocation import com.dot.gallery.feature_node.presentation.util.launchEditIntent import com.dot.gallery.feature_node.presentation.util.launchMap import com.dot.gallery.feature_node.presentation.util.launchOpenWithIntent import com.dot.gallery.feature_node.presentation.util.launchUseAsIntent +import com.dot.gallery.feature_node.presentation.util.printDebug import com.dot.gallery.feature_node.presentation.util.rememberActivityResult import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberExifInterface import com.dot.gallery.feature_node.presentation.util.rememberExifMetadata -import com.dot.gallery.feature_node.presentation.util.rememberGeocoder import com.dot.gallery.feature_node.presentation.util.rememberMediaInfo import com.dot.gallery.feature_node.presentation.util.shareMedia +import com.dot.gallery.feature_node.presentation.util.writeRequest 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 @Composable -fun BoxScope.MediaViewBottomBar( - showDeleteButton: Boolean = true, - bottomSheetState: AppBottomSheetState, - handler: MediaHandleUseCase, - albumsState: AlbumState, - showUI: Boolean, - paddingValues: PaddingValues, +fun MediaViewDetails( + albumsState: State, + vaultState: State, currentMedia: Media?, - currentIndex: Int = 0, - vaults: List, - addMedia: (Vault, Media) -> Unit, - onDeleteMedia: ((Int) -> Unit)? = null, + handler: MediaHandleUseCase, + addMediaToVault: (Vault, Media) -> Unit, ) { - AnimatedVisibility( - visible = showUI, - enter = enterAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), - exit = exitAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), + Column( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) - ) { - Row( - modifier = Modifier - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, BlackScrim) - ) - ) - .padding( - top = 24.dp, - bottom = paddingValues.calculateBottomPadding() + .clip( + RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp ) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .align(Alignment.BottomCenter), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly, + ) + .background( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + ) { + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - currentMedia?.let { - MediaViewActions( - currentIndex = currentIndex, - currentMedia = it, - handler = handler, - onDeleteMedia = onDeleteMedia, - showDeleteButton = showDeleteButton - ) - } + DragHandle() } - } - currentMedia?.let { - MediaInfoBottomSheet( - media = it, - state = bottomSheetState, - albumsState = albumsState, - handler = handler, - vaults = vaults, - addMedia = addMedia - ) - } -} -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MediaInfoBottomSheet( - media: Media, - state: AppBottomSheetState, - albumsState: AlbumState, - vaults: List, - handler: MediaHandleUseCase, - addMedia: (Vault, Media) -> Unit -) { - val scope = rememberCoroutineScope() - val exifInterface = rememberExifInterface(media, true) - val metadataState = rememberAppBottomSheetState() - if (exifInterface != null) { - val exifMetadata = rememberExifMetadata(media, exifInterface) - val mediaInfoList = rememberMediaInfo( - media = media, - exifMetadata = exifMetadata, - onLabelClick = { - scope.launch { - state.hide() - metadataState.show() - } - } - ) - if (state.isVisible) { - ModalBottomSheet( - onDismissRequest = { - scope.launch { - state.hide() + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier.fillMaxWidth(), + visible = currentMedia != null && !currentMedia.isTrashed, + enter = enterAnimation, + exit = exitAnimation + ) { + Column { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val exifInterface = rememberExifInterface(currentMedia!!, true) + val exifMetadata = rememberExifMetadata(currentMedia, exifInterface) + var exifAttributes by rememberExifAttributes(exifInterface) + val exifAttributesEditResult = rememberActivityResult( + onResultOk = { + scope.launch { + if (handler.updateMediaExif(currentMedia, exifAttributes)) { + printDebug("Exif Attributes Updated") + } else { + Toast.makeText(context, "Exif Update failed", Toast.LENGTH_SHORT).show() + } + } } - }, - dragHandle = { DragHandle() }, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetState = state.sheetState, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } - ) { - BackHandler { - scope.launch { - state.hide() + ) + + val dateCaption = rememberMediaDateCaption(exifMetadata, currentMedia) + val metadataState = rememberAppBottomSheetState() + val mediaInfoList = rememberMediaInfo( + media = currentMedia, + exifMetadata = exifMetadata, + onLabelClick = { + if (!currentMedia.readUriOnly) { + scope.launch { + metadataState.show() + } + } } - } - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) + ) + + val locationData = rememberLocationData(exifMetadata, currentMedia) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), ) { - MediaViewInfoActions(media, albumsState, vaults, handler, addMedia) - Spacer(modifier = Modifier.height(8.dp)) - MediaInfoDateCaptionContainer(media, exifMetadata) { - scope.launch { - state.hide() - metadataState.show() - } + item { + DateHeader( + modifier = Modifier.fillMaxWidth(), + mediaDateCaption = dateCaption + ) } - Spacer(modifier = Modifier.height(8.dp)) - MediaInfoMapPreview(exifMetadata) - if (mediaInfoList.isNotEmpty()) { - Spacer(modifier = Modifier.height(16.dp)) - Text( + item { + Row( modifier = Modifier + .fillMaxWidth() + .horizontalScroll(state = rememberScrollState()) .padding(horizontal = 16.dp) - .height(32.dp), - text = stringResource(R.string.media_details), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (currentMedia.isRaw) { + MediaInfoChip2( + text = currentMedia.fileExtension.toUpperCase(Locale.current), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + items( + items = mediaInfoList + ) { + MediaInfoRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + label = it.label, + content = it.content, + trailingContent = { + if (it.trailingIcon != null && !currentMedia.readUriOnly) { + MediaInfoChip2( + text = stringResource(R.string.edit), + contentColor = MaterialTheme.colorScheme.secondary, + containerColor = MaterialTheme.colorScheme.secondary.copy( + alpha = 0.1f + ), + onClick = { + scope.launch { + metadataState.show() + } + } + ) + } + }, + onClick = it.onClick ) - for (metadata in mediaInfoList) { - MediaInfoRow( - label = metadata.label, - content = metadata.content, - icon = metadata.icon, - trailingIcon = metadata.trailingIcon, - onClick = metadata.onClick, - ) + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + item { + MediaViewInfoActions2( + media = currentMedia, + albumsState = albumsState, + vaults = vaultState, + handler = handler, + addMedia = addMediaToVault + ) + } + item { + LocationItem( + locationData = locationData + ) + } + item { + androidx.compose.animation.AnimatedVisibility( + visible = !currentMedia.readUriOnly + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .clip(RoundedCornerShape(16.dp)), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + AnimatedVisibility( + visible = locationData != null + ) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(2.dp)) + .clickable { + scope.launch { + exifAttributes = exifAttributes.copy( + gpsLatLong = null + ) + exifAttributesEditResult.launch( + currentMedia.writeRequest(context.contentResolver) + ) + } + }, + headlineContent = { + Text("Delete Location") + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.GpsOff, + contentDescription = "Delete Location" + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.primary.copy( + alpha = 0.1f + ), + headlineColor = MaterialTheme.colorScheme.primary, + leadingIconColor = MaterialTheme.colorScheme.primary + ) + ) + } + AnimatedVisibility( + visible = exifMetadata?.lensDescription != null + ) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(2.dp)) + .clickable { + scope.launch { + exifAttributes = ExifAttributes() + exifAttributesEditResult.launch( + currentMedia.writeRequest(context.contentResolver) + ) + } + }, + headlineContent = { + Text("Delete Metadata") + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.LocalFireDepartment, + contentDescription = "Delete Metadata" + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.primary.copy( + alpha = 0.1f + ), + headlineColor = MaterialTheme.colorScheme.primary, + leadingIconColor = MaterialTheme.colorScheme.primary + ) + ) + } + } } } - Spacer(modifier = Modifier.navigationBarsPadding()) + item { + NavigationBarSpacer() + } + } + + if (metadataState.isVisible) { + MetadataEditSheet( + state = metadataState, + media = currentMedia, + handle = handler + ) } } } - if (metadataState.isVisible) { - MetadataEditSheet( - state = metadataState, - media = media, - handle = handler - ) - } } } @Composable -fun MediaInfoDateCaptionContainer( - media: Media, - exifMetadata: ExifMetadata, - onClickEditButton: () -> Unit = {} +fun DateHeader( + modifier: Modifier = Modifier, + mediaDateCaption: MediaDateCaption ) { - Column { - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = Shapes.large - ) - .padding(vertical = 16.dp) - .padding(start = 16.dp, end = 12.dp), - ) { - Column( - modifier = Modifier - .align(Alignment.TopStart) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + Text( + text = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ).toSpanStyle() ) { - Text( - text = media.timestamp.getDate(Constants.EXIF_DATE_FORMAT), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp - ) - val defaultDesc = stringResource(R.string.image_add_description) - val imageDesc = remember(exifMetadata) { - val lensDesc = exifMetadata.lensDescription - val imageCapt = exifMetadata.imageDescription - return@remember if (lensDesc != null && !imageCapt.isNullOrBlank() && imageCapt != lensDesc) { - "$lensDesc\n$imageCapt" - } else lensDesc ?: (imageCapt ?: defaultDesc) - } - SelectionContainer { - Text( - text = imageDesc, - style = MaterialTheme.typography.bodyMedium, + appendLine(mediaDateCaption.date) + } + mediaDateCaption.deviceInfo?.let { deviceInfo -> + withStyle( + style = MaterialTheme.typography.bodySmall.copy( color = MaterialTheme.colorScheme.onSurfaceVariant - ) + ).toSpanStyle() + ) { + appendLine(deviceInfo) } } + withStyle( + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ).toSpanStyle() + ) { + append( + mediaDateCaption.description.ifEmpty { + stringResource(R.string.image_add_description) + } + ) + } + }, + overflow = TextOverflow.Ellipsis, + modifier = modifier + .padding(top = 16.dp) + .padding(horizontal = 32.dp), + ) +} - if (!media.readUriOnly) { - IconButton( - modifier = Modifier.align(Alignment.TopEnd), - onClick = onClickEditButton +@Suppress("KotlinConstantConditions") +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun LocationItem( + modifier: Modifier = Modifier, + locationData: LocationData? +) { + AnimatedVisibility( + visible = locationData != null, + enter = enterAnimation, + exit = exitAnimation + ) { + if (locationData != null) { + val context = LocalContext.current + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = Shapes.large + ) + .clip(Shapes.large) + .clickable { + context.launchMap(locationData.latitude, locationData.longitude) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(2f) + .padding(start = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) ) { + Spacer(modifier = Modifier.height(4.dp)) Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(id = R.string.edit_cd), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp) + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = stringResource(R.string.open_with), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.location), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = locationData.location, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary ) + Spacer(modifier = Modifier.height(4.dp)) } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(state = rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (media.isRaw) { - MediaInfoChip( - text = media.fileExtension.toUpperCase(Locale.current), - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - if (exifMetadata.formattedCords != null) { - val geocoder = rememberGeocoder() - val clipboardManager: ClipboardManager = LocalClipboardManager.current - var locationName by remember { mutableStateOf(exifMetadata.formattedCords!!) } - LaunchedEffect(geocoder) { - geocoder?.getLocation( - exifMetadata.gpsLatLong!![0], - exifMetadata.gpsLatLong[1] - ) { address -> - address?.let { - val addressName = it.formattedAddress - if (addressName.isNotEmpty()) { - locationName = addressName - } - } + + val connection by connectivityState() + + AnimatedVisibility( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .clip(Shapes.large), + visible = remember(connection) { + connection.isConnected() && BuildConfig.MAPS_TOKEN != "DEBUG" } + ) { + AsyncImage( + uri = MapBoxURL( + latitude = locationData.latitude, + longitude = locationData.longitude, + darkTheme = isSystemInDarkTheme() + ), + contentScale = ContentScale.Crop, + contentDescription = stringResource(R.string.location_map_cd), + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .clip(Shapes.large) + ) } - MediaInfoChip( - text = stringResource(R.string.location_chip, locationName), - onLongClick = { - clipboardManager.setText(AnnotatedString(exifMetadata.formattedCords!!)) - } - ) } } } @@ -377,7 +499,7 @@ fun MediaInfoDateCaptionContainer( @OptIn(ExperimentalFoundationApi::class) @Composable -fun MediaInfoChip( +fun MediaInfoChip2( text: String, contentColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, @@ -410,59 +532,11 @@ fun MediaInfoChip( ) } -@Suppress("KotlinConstantConditions") -@OptIn( - ExperimentalCoroutinesApi::class -) -@Composable -fun MediaInfoMapPreview(exifMetadata: ExifMetadata) { - if (exifMetadata.gpsLatLong != null) { - val context = LocalContext.current - val lat = exifMetadata.gpsLatLong[0] - val long = exifMetadata.gpsLatLong[1] - val connection by connectivityState() - if (connection.isConnected() && BuildConfig.MAPS_TOKEN != "DEBUG") { - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(Shapes.large) - .background(MaterialTheme.colorScheme.surface) - ) { - AsyncImage( - uri = MapBoxURL( - latitude = lat, - longitude = long, - darkTheme = isSystemInDarkTheme() - ), - contentScale = ContentScale.FillWidth, - contentDescription = stringResource(R.string.location_map_cd), - modifier = Modifier - .clip(Shapes.large) - .fillMaxWidth() - .aspectRatio(1.5f) - .clickable { context.launchMap(lat, long) } - ) - Icon( - modifier = Modifier - .padding(16.dp) - .size(40.dp) - .padding(8.dp) - .align(Alignment.TopEnd), - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - } -} - @Composable -private fun MediaViewInfoActions( +private fun MediaViewInfoActions2( media: Media, - albumsState: AlbumState, - vaults: List, + albumsState: State, + vaults: State, handler: MediaHandleUseCase, addMedia: (Vault, Media) -> Unit ) { @@ -482,38 +556,65 @@ private fun MediaViewInfoActions( // Use as or Open With OpenAsButton(media, followTheme = true) // Copy - CopyButton(media, albumsState, handler, followTheme = true) + CopyButton(media, albumsState.value, handler, followTheme = true) // Move - MoveButton(media, albumsState, handler, followTheme = true) + MoveButton(media, albumsState.value, handler, followTheme = true) // Edit EditButton(media, followTheme = true) } } @Composable -private fun MediaViewActions( +fun MediaViewActions2( currentIndex: Int, currentMedia: Media, handler: MediaHandleUseCase, onDeleteMedia: ((Int) -> Unit)?, showDeleteButton: Boolean ) { - // Share Component - ShareButton(currentMedia) - // Favorite Component - FavoriteButton(currentMedia, handler) - // Edit - EditButton(currentMedia) - // Trash Component - if (showDeleteButton) { - TrashButton(currentIndex, currentMedia, handler, false, onDeleteMedia) + if (currentMedia.isTrashed) { + val scope = rememberCoroutineScope() + val result = rememberActivityResult() + // Restore Component + BottomBarColumn( + currentMedia = currentMedia, + imageVector = Icons.Outlined.RestoreFromTrash, + title = stringResource(id = R.string.trash_restore) + ) { + scope.launch { + onDeleteMedia?.invoke(currentIndex) + handler.trashMedia(result = result, arrayListOf(it), trash = false) + } + } + // Delete Component + BottomBarColumn( + currentMedia = currentMedia, + imageVector = Icons.Outlined.DeleteOutline, + title = stringResource(id = R.string.trash_delete) + ) { + scope.launch { + onDeleteMedia?.invoke(currentIndex) + handler.deleteMedia(result = result, arrayListOf(it)) + } + } + } else { + // Share Component + ShareButton(currentMedia) + // Favorite Component + FavoriteButton(currentMedia, handler) + // Edit + EditButton(currentMedia) + // Trash Component + if (showDeleteButton) { + TrashButton(currentIndex, currentMedia, handler, false, onDeleteMedia) + } } } @Composable -private fun HideButton( +fun HideButton( media: Media, - vaults: List, + vaults: State, addMedia: (Vault, Media) -> Unit, followTheme: Boolean = false ) { @@ -523,27 +624,46 @@ private fun HideButton( currentMedia = media, imageVector = Icons.Outlined.Lock, followTheme = followTheme, - enabled = vaults.isNotEmpty(), + enabled = remember(vaults.value) { + vaults.value.vaults.isNotEmpty() + }, title = stringResource(R.string.hide) ) { scope.launch { sheetState.show() } } - + val context = LocalContext.current + val result = rememberActivityResult(onResultOk = { + scope.launch { + sheetState.hide() + } + }) + val vaultState by remember(vaults.value) { vaults } SelectVaultSheet( state = sheetState, - vaults = vaults, + vaultState = vaultState, onVaultSelected = { vault -> scope.launch { - addMedia(vault, media) + addMedia(vault, media).also { + val intentSender = + MediaStore.createDeleteRequest( + context.contentResolver, + listOf(media.uri) + ).intentSender + val senderRequest: IntentSenderRequest = + IntentSenderRequest.Builder(intentSender) + .setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION, 0) + .build() + result.launch(senderRequest) + } } } ) } @Composable -private fun CopyButton( +fun CopyButton( media: Media, albumsState: AlbumState, handler: MediaHandleUseCase, @@ -572,7 +692,7 @@ private fun CopyButton( } @Composable -private fun MoveButton( +fun MoveButton( media: Media, albumsState: AlbumState, handler: MediaHandleUseCase, @@ -601,7 +721,7 @@ private fun MoveButton( } @Composable -private fun ShareButton( +fun ShareButton( media: Media, followTheme: Boolean = false ) { @@ -620,7 +740,7 @@ private fun ShareButton( } @Composable -private fun FavoriteButton( +fun FavoriteButton( media: Media, handler: MediaHandleUseCase, followTheme: Boolean = false @@ -654,7 +774,7 @@ private fun FavoriteButton( } @Composable -private fun EditButton( +fun EditButton( media: Media, followTheme: Boolean = false ) { @@ -670,7 +790,7 @@ private fun EditButton( } @Composable -private fun OpenAsButton( +fun OpenAsButton( media: Media, followTheme: Boolean = false ) { @@ -698,7 +818,7 @@ private fun OpenAsButton( } @Composable -private fun TrashButton( +fun TrashButton( index: Int, media: Media, handler: MediaHandleUseCase, @@ -759,7 +879,10 @@ fun BottomBarColumn( onItemClick: (Media) -> Unit ) { val alpha = if (enabled) 1f else 0.5f - val tintColor = if (followTheme) MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) else Color.White.copy(alpha = alpha) + val tintColor = + if (followTheme) MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) else Color.White.copy( + alpha = alpha + ) Column( modifier = Modifier .clip(RoundedCornerShape(12.dp)) diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt index 7ba9d52cb..76eb26a28 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/mediaview/components/MediaInfo.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.outlined.ImageSearch import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.VideoFile -import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text @@ -25,13 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import com.dot.gallery.R import com.dot.gallery.core.Constants.TAG +import com.dot.gallery.feature_node.domain.model.InfoRow import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.util.ExifMetadata import com.dot.gallery.feature_node.presentation.util.formatMinSec @@ -46,11 +45,9 @@ fun MediaInfoRow( modifier: Modifier = Modifier, label: String, content: String, - icon: ImageVector, - trailingIcon: ImageVector? = null, - contentDescription: String? = null, + trailingContent: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null ) { val clipboardManager: ClipboardManager = LocalClipboardManager.current ListItem( @@ -72,40 +69,17 @@ fun MediaInfoRow( headlineContent = { Text( text = label, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Bold ) }, supportingContent = { Text(text = content) }, - trailingContent = if (trailingIcon != null) { - { - Icon( - imageVector = trailingIcon, - contentDescription = contentDescription - ) - } - } else null, - leadingContent = { - Icon( - imageVector = icon, - contentDescription = contentDescription - ) - } + trailingContent = trailingContent ) } -data class InfoRow( - val label: String, - val content: String, - val icon: ImageVector, - val trailingIcon: ImageVector? = null, - val contentDescription: String? = null, - val onClick: (() -> Unit)? = null, - val onLongClick: (() -> Unit)? = null, -) - -fun Media.retrieveMetadata(context: Context, exifMetadata: ExifMetadata, onLabelClick: () -> Unit): List { +fun Media.retrieveMetadata(context: Context, exifMetadata: ExifMetadata?, onLabelClick: () -> Unit): List { val infoList = ArrayList() if (trashed == 1) { infoList.apply { @@ -130,7 +104,7 @@ fun Media.retrieveMetadata(context: Context, exifMetadata: ExifMetadata, onLabel } try { infoList.apply { - if (!exifMetadata.modelName.isNullOrEmpty()) { + if (exifMetadata != null && !exifMetadata.modelName.isNullOrEmpty()) { val aperture = exifMetadata.apertureValue val focalLength = exifMetadata.focalLength val isoValue = exifMetadata.isoValue @@ -163,7 +137,7 @@ fun Media.retrieveMetadata(context: Context, exifMetadata: ExifMetadata, onLabel contentString.append(formattedFileSize) if (mimeType.contains("video")) { contentString.append(" • ${duration.formatMinSec()}") - } else if (exifMetadata.imageWidth != 0 && exifMetadata.imageHeight != 0) { + } else if (exifMetadata != null && exifMetadata.imageWidth != 0 && exifMetadata.imageHeight != 0) { val width = exifMetadata.imageWidth val height = exifMetadata.imageHeight val imageMp = exifMetadata.imageMp 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 e2687fbca..933d2fee4 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 @@ -18,6 +18,7 @@ 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.domain.model.isVideo import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayer @Stable @@ -28,12 +29,14 @@ fun MediaPreviewComponent( uiEnabled: Boolean, playWhenReady: Boolean, onItemClick: () -> Unit, + onSwipeDown: () -> Unit, videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, ) { Box( modifier = Modifier.fillMaxSize(), ) { AnimatedVisibility( + modifier = Modifier.fillMaxSize(), visible = media.isVideo, enter = fadeIn(), exit = fadeOut() @@ -42,7 +45,8 @@ fun MediaPreviewComponent( media = media, playWhenReady = playWhenReady, videoController = videoController, - onItemClick = onItemClick + onItemClick = onItemClick, + onSwipeDown = onSwipeDown ) } @@ -54,7 +58,8 @@ fun MediaPreviewComponent( ZoomablePagerImage( media = media, uiEnabled = uiEnabled, - onItemClick = onItemClick + onItemClick = onItemClick, + onSwipeDown = onSwipeDown ) } } 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 4fddf4a0f..96368f732 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 @@ -28,6 +28,7 @@ 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.core.presentation.components.util.swipe import com.dot.gallery.feature_node.domain.model.Media import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.request.ComposableImageRequest @@ -44,7 +45,8 @@ fun ZoomablePagerImage( modifier: Modifier = Modifier, media: Media, uiEnabled: Boolean, - onItemClick: () -> Unit + onItemClick: () -> Unit, + onSwipeDown: () -> Unit ) { ProvideBatteryStatus { val allowBlur by Settings.Misc.rememberAllowBlur() @@ -80,10 +82,17 @@ fun ZoomablePagerImage( SketchZoomAsyncImage( zoomState = zoomState, - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .swipe( + onSwipeDown = onSwipeDown, + onSwipeUp = null + ), 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 6958a0ba6..fd337426a 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 @@ -35,6 +35,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.core.presentation.components.util.swipe import com.dot.gallery.feature_node.domain.model.Media import io.sanghun.compose.video.RepeatMode import io.sanghun.compose.video.uri.VideoPlayerMediaItem @@ -51,7 +52,8 @@ fun VideoPlayer( media: Media, playWhenReady: Boolean, videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, - onItemClick: () -> Unit + onItemClick: () -> Unit, + onSwipeDown: () -> Unit ) { var totalDuration by rememberSaveable { mutableLongStateOf(0L) } val currentTime = rememberSaveable { mutableLongStateOf(0L) } @@ -133,6 +135,10 @@ fun VideoPlayer( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onItemClick, + ) + .swipe( + onSwipeDown = onSwipeDown, + onSwipeUp = null ), ) } 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 87da66928..76b73c9e3 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 @@ -15,6 +15,7 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -65,7 +66,6 @@ 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 @@ -89,7 +89,7 @@ fun VideoPlayerController( modifier = Modifier .align(Alignment.BottomStart) .padding(horizontal = 16.dp) - .padding(bottom = paddingValues.calculateBottomPadding() + 72.dp) + .padding(bottom = paddingValues.calculateBottomPadding() + 82.dp) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End @@ -190,13 +190,14 @@ fun VideoPlayerController( } var sliderValue by rememberSaveable(currentTime.longValue) { mutableFloatStateOf(currentTime.longValue.toFloat()) } Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() + .padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { Text( modifier = Modifier.width(52.dp), - text = max(sliderValue, currentTime.longValue.toFloat()).toLong().formatMinSec(), + text = sliderValue.toLong().formatMinSec(), fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium, color = Color.White, @@ -224,7 +225,7 @@ fun VideoPlayerController( }, track = { SliderDefaults.Track( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(100)), + modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(100)), sliderState = it, colors = disabledColors, drawStopIndicator = null, @@ -254,7 +255,7 @@ fun VideoPlayerController( }, track = { SliderDefaults.Track( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(100)), + modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(100)), sliderState = it, colors = activeColors, drawStopIndicator = null, @@ -298,7 +299,7 @@ fun VideoPlayerController( .align(Alignment.Center) .size(64.dp) ) { - if (isPlaying.value) { + if (isPlaying.value && player.isPlaying) { Image( modifier = Modifier.fillMaxSize(), imageVector = Icons.Filled.PauseCircleFilled, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt index 490599dc2..3edc7b4d8 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/PickerViewModel.kt @@ -7,86 +7,78 @@ package com.dot.gallery.feature_node.presentation.picker import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.core.AlbumState -import com.dot.gallery.core.MediaState +import com.dot.gallery.feature_node.domain.model.AlbumState +import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.Album -import com.dot.gallery.feature_node.domain.use_case.MediaUseCases -import com.dot.gallery.feature_node.presentation.util.collectMedia +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.presentation.util.mapMedia import com.dot.gallery.feature_node.presentation.util.mediaFlowWithType import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel open class PickerViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases + private val repository: MediaRepository ) : ViewModel() { + var allowedMedia: AllowedMedia = AllowedMedia.BOTH + var albumId: Long = -1L + set(value) { + field = value + mediaState = lazy { + repository.mediaFlowWithType(value, allowedMedia) + .mapMedia(albumId = value, groupByMonth = false, withMonthHeader = false, updateDatabase = {}) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MediaState()) + } + } - private val _mediaState = MutableStateFlow(MediaState()) - val mediaState = _mediaState.asStateFlow() - - private val _albumsState = MutableStateFlow(AlbumState()) - val albumsState = _albumsState.asStateFlow() - - fun init(allowedMedia: AllowedMedia) { - this.allowedMedia = allowedMedia - getMedia(albumId, allowedMedia) - getAlbums(allowedMedia) + var mediaState = lazy { + repository.mediaFlowWithType(albumId, allowedMedia) + .mapMedia(albumId = albumId, groupByMonth = false, withMonthHeader = false, updateDatabase = {}) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MediaState()) } - fun getAlbum(albumId: Long) { - this.albumId = albumId - getMedia(albumId, allowedMedia) + val albumsState by lazy { + repository.getAlbumsWithType(allowedMedia) + .map { result -> + val data = result.data ?: emptyList() + val error = if (result is Resource.Error) result.message + ?: "An error occurred" else "" + if (data.isEmpty()) { + return@map AlbumState(albums = listOf(emptyAlbum), error = error) + } + val albums = mutableListOf().apply { + add(emptyAlbum) + addAll(data) + } + AlbumState(albums = albums, error = error) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AlbumState()) } - private var allowedMedia: AllowedMedia = AllowedMedia.BOTH - - var albumId: Long = -1L private val emptyAlbum = Album(id = -1, label = "All", uri = Uri.EMPTY, pathToThumbnail = "", timestamp = 0, relativePath = "") +} - private fun getAlbums(allowedMedia: AllowedMedia) { - viewModelScope.launch(Dispatchers.IO) { - mediaUseCases.getAlbumsWithTypeUseCase(allowedMedia).flowOn(Dispatchers.IO) - .collectLatest { result -> - val data = result.data ?: emptyList() - val error = if (result is Resource.Error) result.message - ?: "An error occurred" else "" - if (data.isEmpty()) { - return@collectLatest _albumsState.emit(AlbumState(albums = listOf(emptyAlbum), error = error)) - } - val albums = mutableListOf().apply { - add(emptyAlbum) - addAll(data) - } - _albumsState.emit(AlbumState(albums = albums, error = error)) - } +enum class AllowedMedia { + PHOTOS, VIDEOS, BOTH; + + override fun toString(): String { + return when (this) { + PHOTOS -> "image%" + VIDEOS -> "video%" + BOTH -> "%/%" } } - private fun getMedia(albumId: Long, allowedMedia: AllowedMedia) { - viewModelScope.launch(Dispatchers.IO) { - mediaUseCases.mediaFlowWithType(albumId, allowedMedia).flowOn(Dispatchers.IO) - .collectLatest { result -> - val data = result.data ?: emptyList() - val error = if (result is Resource.Error) result.message - ?: "An error occurred" else "" - if (data.isEmpty()) { - return@collectLatest _mediaState.emit(MediaState(isLoading = false)) - } - _mediaState.collectMedia(data, error, albumId) - } + fun toStringAny(): String { + return when (this) { + PHOTOS -> "image/*" + VIDEOS -> "video/*" + BOTH -> "*/*" } - } -} - -enum class AllowedMedia { - PHOTOS, VIDEOS, BOTH } \ No newline at end of file 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 f339cde3f..5072f6a57 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 @@ -24,13 +24,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R -import com.dot.gallery.core.MediaState -import com.dot.gallery.core.presentation.components.StickyHeader +import com.dot.gallery.feature_node.domain.model.MediaState +import com.dot.gallery.core.presentation.components.MediaItemHeader import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaItem import com.dot.gallery.feature_node.domain.model.isHeaderKey import com.dot.gallery.feature_node.presentation.common.components.MediaImage -import com.dot.gallery.feature_node.presentation.util.FeedbackManager +import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager import com.dot.gallery.ui.theme.Dimens import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -47,7 +47,7 @@ fun PickerMediaScreen( val state by mediaState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() val isCheckVisible = rememberSaveable { mutableStateOf(allowSelection) } - val feedbackManager = FeedbackManager.rememberFeedbackManager() + val feedbackManager = rememberFeedbackManager() LazyVerticalGrid( state = gridState, @@ -66,17 +66,18 @@ fun PickerMediaScreen( ) { item -> when (item) { is MediaItem.Header -> { + val isChecked = rememberSaveable { mutableStateOf(false) } if (allowSelection) { LaunchedEffect(selectedMedia.size) { // Partial check of media items should not check the header - isChecked.value = selectedMedia.containsAll(item.data) + isChecked.value = selectedMedia.map { it.id }.containsAll(item.data) } } val title = item.text .replace("Today", stringToday) .replace("Yesterday", stringYesterday) - StickyHeader( + MediaItemHeader( date = title, showAsBig = item.key.contains("big"), isCheckVisible = isCheckVisible, @@ -89,10 +90,10 @@ fun PickerMediaScreen( if (isChecked.value) { val toAdd = item.data.toMutableList().apply { // Avoid media from being added twice to selection - removeIf { selectedMedia.contains(it) } + removeIf { selectedMedia.map { it.id }.contains(it) } } - selectedMedia.addAll(toAdd) - } else selectedMedia.removeAll(item.data) + selectedMedia.addAll(mediaState.value.media.filter { toAdd.contains(it.id) }) + } else selectedMedia.removeAll { item.data.contains(it.id) } } } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt index a49bb08fa..90a9e54eb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/picker/components/PickerScreen.kt @@ -59,7 +59,7 @@ fun PickerScreen( var selectedAlbumIndex by remember { mutableLongStateOf(-1) } val selectedMedia = remember { mutableStateListOf() } val mediaVM = hiltViewModel().apply { - init(allowedMedia = allowedMedia) + this.allowedMedia = allowedMedia } val albumsState by mediaVM.albumsState.collectAsStateWithLifecycle() val chipColors = InputChipDefaults.inputChipColors( @@ -86,7 +86,7 @@ fun PickerScreen( InputChip( onClick = { selectedAlbumIndex = it.id - mediaVM.getAlbum(selectedAlbumIndex) + mediaVM.albumId = selectedAlbumIndex }, colors = chipColors, shape = RoundedCornerShape(16.dp), @@ -103,7 +103,7 @@ fun PickerScreen( modifier = Modifier.fillMaxSize() ) { PickerMediaScreen( - mediaState = mediaVM.mediaState, + mediaState = mediaVM.mediaState.value, selectedMedia = selectedMedia, allowSelection = allowSelection, ) @@ -161,6 +161,6 @@ fun PickerScreen( } BackHandler(selectedAlbumIndex != -1L) { selectedAlbumIndex = -1L - mediaVM.getAlbum(selectedAlbumIndex) + mediaVM.albumId = -1L } } \ No newline at end of file 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 1bb09c722..c1dd8b926 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 @@ -40,17 +40,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dokar.pinchzoomgrid.PinchZoomGridLayout import com.dokar.pinchzoomgrid.rememberPinchZoomGridState @@ -61,6 +62,7 @@ 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.EmptyMedia import com.dot.gallery.core.presentation.components.LoadingMedia import com.dot.gallery.feature_node.presentation.common.MediaViewModel import com.dot.gallery.feature_node.presentation.common.components.MediaGridView @@ -68,12 +70,13 @@ import com.dot.gallery.feature_node.presentation.search.components.SearchBarElev import com.dot.gallery.feature_node.presentation.search.components.SearchBarElevation.Expanded import com.dot.gallery.feature_node.presentation.search.components.SearchHistory import com.dot.gallery.feature_node.presentation.util.Screen +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainSearchBar( - mediaViewModel: MediaViewModel, bottomPadding: Dp, selectionState: MutableState? = null, navigate: (String) -> Unit, @@ -84,30 +87,41 @@ fun MainSearchBar( ) { var historySet by rememberSearchHistory() var query by rememberSaveable { mutableStateOf("") } - val mediaState by mediaViewModel.mediaState.collectAsStateWithLifecycle() - LaunchedEffect(mediaState) { - if (query.isNotEmpty()) { + val mediaViewModel: MediaViewModel = hiltViewModel().also { + it.mediaFlow.collectAsStateWithLifecycle() + } + val state = mediaViewModel.searchMediaState.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + LaunchedEffect(state.value.media) { + if (query.isNotEmpty() && state.value.media.isEmpty()) { mediaViewModel.queryMedia(query) } } - val state by mediaViewModel.searchMediaState.collectAsStateWithLifecycle() + val alpha by animateFloatAsState( targetValue = if (selectionState != null && selectionState.value) 0.6f else 1f, label = "alpha" ) - val scope = rememberCoroutineScope() val elevation by animateDpAsState( targetValue = if (activeState.value) Expanded() else Collapsed(), label = "elevation" ) - LaunchedEffect(LocalConfiguration.current, activeState.value) { + LaunchedEffect(activeState.value) { if (selectionState == null) { toggleNavbar(!activeState.value) } } val hideSearchBarSetting by rememberAutoHideSearchBar() - val shouldHide by remember(isScrolling.value, hideSearchBarSetting) { - mutableStateOf(if (hideSearchBarSetting) isScrolling.value else false) + + var shouldHide by remember { mutableStateOf(if (hideSearchBarSetting) isScrolling.value else false) } + + LaunchedEffect(isScrolling.value) { + snapshotFlow { isScrolling.value } + .distinctUntilChanged() + .collectLatest { + shouldHide = if (hideSearchBarSetting) it else false + } } Box( @@ -117,12 +131,8 @@ fun MainSearchBar( .alpha(alpha) .fillMaxWidth() ) { - /** - * TODO: fillMaxWidth with fixed lateral padding on the search container only - * It is not yet possible because of the material3 compose limitations - */ val searchBarAlpha by animateFloatAsState( - targetValue = remember(shouldHide) { if (shouldHide) 0f else 1f }, + targetValue = if (shouldHide) 0f else 1f, label = "searchBarAlpha" ) val onActiveChange: (Boolean) -> Unit = { activeState.value = it } @@ -130,33 +140,36 @@ fun MainSearchBar( 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() + scope.launch { + 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") } + historySet = historySet.toMutableSet().apply { add("${System.currentTimeMillis()}/$it") } mediaViewModel.queryMedia(it) }, expanded = activeState.value, onExpandedChange = onActiveChange, - enabled = (selectionState == null || !selectionState.value) && !shouldHide, + enabled = remember(selectionState?.value, shouldHide) { + (selectionState == null || !selectionState.value) && !shouldHide + }, placeholder = { Text(text = stringResource(id = R.string.searchbar_title)) }, leadingIcon = { IconButton( - enabled = selectionState == null, + enabled = remember(selectionState) { + selectionState == null + }, onClick = { scope.launch { activeState.value = !activeState.value @@ -164,8 +177,10 @@ fun MainSearchBar( mediaViewModel.clearQuery() } }) { - val leadingIcon = if (activeState.value) - Icons.AutoMirrored.Outlined.ArrowBack else Icons.Outlined.Search + val leadingIcon = remember(activeState.value) { + if (activeState.value) + Icons.AutoMirrored.Outlined.ArrowBack else Icons.Outlined.Search + } Icon( imageVector = leadingIcon, modifier = Modifier.fillMaxHeight(), @@ -175,7 +190,11 @@ fun MainSearchBar( }, trailingIcon = { Row { - if (!activeState.value) menuItems?.invoke(this) + androidx.compose.animation.AnimatedVisibility( + visible = !activeState.value, + ) { + menuItems?.invoke(this@Row) + } } }, interactionSource = remember { MutableInteractionSource() }, @@ -189,9 +208,6 @@ fun MainSearchBar( colors = colors, tonalElevation = elevation, content = { - /** - * Recent searches - */ val lastQueryIsEmpty = remember(mediaViewModel.lastQuery.value) { mediaViewModel.lastQuery.value.isEmpty() } AnimatedVisibility( @@ -205,15 +221,12 @@ fun MainSearchBar( } } - /** - * Searched content - */ AnimatedVisibility( visible = !lastQueryIsEmpty, enter = enterAnimation, exit = exitAnimation ) { - val mediaIsEmpty = remember(state) { state.media.isEmpty() && !state.isLoading } + val mediaIsEmpty = remember(state) { state.value.media.isEmpty() && !state.value.isLoading } if (mediaIsEmpty) { Column( modifier = Modifier @@ -257,16 +270,18 @@ fun MainSearchBar( mediaState = state, paddingValues = pd, canScroll = canScroll, - isScrolling = remember { mutableStateOf(false) } - ) { - navigate(Screen.MediaViewScreen.route + "?mediaId=${it.id}") - } + isScrolling = remember { mutableStateOf(false) }, + onMediaClick = { + navigate(Screen.MediaViewScreen.route + "?mediaId=${it.id}&query=true") + }, + emptyContent = { EmptyMedia() } + ) androidx.compose.animation.AnimatedVisibility( - visible = state.isLoading, + visible = state.value.isLoading, enter = enterAnimation, exit = exitAnimation ) { - LoadingMedia(paddingValues = pd) + LoadingMedia() } } @@ -277,13 +292,15 @@ fun MainSearchBar( } BackHandler(activeState.value) { - if (mediaViewModel.lastQuery.value.isEmpty()) { - activeState.value = false - query = "" - mediaViewModel.queryMedia(query) - } else { - query = "" - mediaViewModel.clearQuery() + scope.launch { + if (mediaViewModel.lastQuery.value.isEmpty()) { + activeState.value = false + query = "" + mediaViewModel.queryMedia(query) + } else { + query = "" + mediaViewModel.clearQuery() + } } } } 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 d9ff7e716..7e34fc983 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 @@ -23,7 +23,6 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.dot.gallery.BuildConfig import com.dot.gallery.R @@ -36,7 +35,6 @@ import com.dot.gallery.feature_node.presentation.util.RepeatOnResume import com.dot.gallery.feature_node.presentation.util.isManageFilesAllowed import com.dot.gallery.feature_node.presentation.util.launchManageFiles import com.dot.gallery.feature_node.presentation.util.launchManageMedia -import com.dot.gallery.ui.theme.GalleryTheme import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import kotlinx.coroutines.launch @@ -67,6 +65,7 @@ fun SetupScreen( painter = painterResource(R.drawable.ic_gallery_thumbnail), title = stringResource(id = R.string.welcome), subtitle = appName, + contentPadding = 0.dp, bottomBar = { OutlinedButton( onClick = { (context as Activity).finish() } @@ -87,6 +86,7 @@ fun SetupScreen( content = { Text( modifier = Modifier + .fillMaxWidth() .padding(bottom = 16.dp) .padding(horizontal = 16.dp), text = stringResource(R.string.required) @@ -112,7 +112,7 @@ fun SetupScreen( } Text( - modifier = Modifier.padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), text = stringResource(R.string.optional) ) val grantedString = stringResource(R.string.granted) @@ -165,11 +165,3 @@ private val Context.requiredPermissionsList: Array> get() { getString(R.string.internet) to getString(R.string.internet_summary) ) } - -@Preview -@Composable -fun SetupPreview() { - GalleryTheme { - SetupScreen() - } -} \ No newline at end of file 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 d7c283891..fe8a6a806 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 @@ -12,11 +12,12 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.Scaffold -import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.core.AlbumState +import com.dot.gallery.feature_node.domain.model.AlbumState import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreen import com.dot.gallery.feature_node.presentation.util.toggleOrientation import com.dot.gallery.ui.theme.GalleryTheme @@ -47,8 +48,8 @@ class StandaloneActivity : ComponentActivity() { reviewMode = action.lowercase().contains("review") dataList = uriList.toList() } - val vaults by viewModel.vaults.collectAsStateWithLifecycle() - val mediaState by viewModel.mediaState.collectAsStateWithLifecycle() + val vaults = viewModel.vaults.collectAsStateWithLifecycle() + val mediaState = viewModel.mediaState.value.collectAsStateWithLifecycle() MediaViewScreen( navigateUp = { finish() }, toggleRotate = ::toggleOrientation, @@ -56,10 +57,12 @@ class StandaloneActivity : ComponentActivity() { isStandalone = true, mediaId = viewModel.mediaId, mediaState = mediaState, - albumsState = AlbumState(), + albumsState = remember { + mutableStateOf(AlbumState()) + }, handler = viewModel.handler, addMedia = viewModel::addMedia, - vaults = vaults + vaultState = vaults ) } BackHandler { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt index 5ad528ae5..620b43472 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt @@ -9,18 +9,18 @@ import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.core.MediaState import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.MediaState import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.use_case.MediaUseCases -import com.dot.gallery.feature_node.domain.use_case.VaultUseCases +import com.dot.gallery.feature_node.domain.model.VaultState +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -28,55 +28,67 @@ import javax.inject.Inject class StandaloneViewModel @Inject constructor( @ApplicationContext private val applicationContext: Context, - private val mediaUseCases: MediaUseCases, - private val vaultUseCases: VaultUseCases + private val repository: MediaRepository, + val handler: MediaHandleUseCase ) : ViewModel() { - private val _mediaState = MutableStateFlow(MediaState()) - val mediaState = _mediaState.asStateFlow() - val handler = mediaUseCases.mediaHandleUseCase var reviewMode: Boolean = false - - var dataList: List = emptyList() set(value) { - if (value.isNotEmpty() && value != dataList) { - getMedia(value) - } field = value + mediaState = lazy { + repository.getMediaListByUris(dataList, reviewMode) + .map { + val data = it.data + if (data != null) { + mediaId = data.first().id + MediaState(media = data) + } else { + mediaFromUris() + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MediaState()) + } } - - var mediaId: Long = -1 - - - private val _vaults = MutableStateFlow>(emptyList()) - val vaults = _vaults.asStateFlow() - - fun addMedia(vault: Vault, media: Media) { - viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.addMedia(vault, media) - } - } - - private fun getMedia(clipDataUriList: List = emptyList()) { - viewModelScope.launch(Dispatchers.IO) { - if (clipDataUriList.isNotEmpty()) { - mediaUseCases.getMediaListByUrisUseCase(clipDataUriList, reviewMode) - .flowOn(Dispatchers.IO) - .collectLatest { result -> - val data = result.data + var dataList: List = emptyList() + set(value) { + field = value + mediaState = lazy { + repository.getMediaListByUris(value, reviewMode) + .map { + val data = it.data if (data != null) { mediaId = data.first().id - _mediaState.value = MediaState(media = data) + MediaState(media = data) } else { - _mediaState.value = mediaFromUris() + mediaFromUris() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MediaState()) } } - viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.getVaults().collectLatest { - _vaults.emit(it) + + var mediaId: Long = -1 + + var mediaState = lazy { + repository.getMediaListByUris(dataList, reviewMode) + .map { + val data = it.data + if (data != null) { + mediaId = data.first().id + MediaState(media = data) + } else { + mediaFromUris() + } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MediaState()) + } + + val vaults = repository.getVaults().map { it.data ?: emptyList() }.map { VaultState(it, isLoading = false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), VaultState()) + + fun addMedia(vault: Vault, media: Media) { + viewModelScope.launch(Dispatchers.IO) { + repository.addMedia(vault, media) } } 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 96a5bbe85..f7c2a57d0 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 @@ -7,33 +7,28 @@ package com.dot.gallery.feature_node.presentation.timeline import android.app.Activity import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.core.AlbumState -import com.dot.gallery.core.MediaState -import com.dot.gallery.core.presentation.components.EmptyMedia +import com.dot.gallery.feature_node.domain.model.AlbumState +import com.dot.gallery.feature_node.domain.model.MediaState 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.timeline.components.TimelineNavActions -import kotlinx.coroutines.flow.StateFlow @Composable fun TimelineScreen( paddingValues: PaddingValues, albumId: Long = -1L, albumName: String = stringResource(R.string.app_name), - vm: MediaViewModel, handler: MediaHandleUseCase, - mediaState: StateFlow, - albumState: StateFlow, + mediaState: State, + albumsState: State, selectionState: MutableState, selectedMedia: SnapshotStateList, allowNavBar: Boolean = true, @@ -50,11 +45,10 @@ fun TimelineScreen( paddingValues = paddingValues, albumId = albumId, target = null, - vm = vm, albumName = albumName, handler = handler, + albumsState = albumsState, mediaState = mediaState, - albumState = albumState, selectionState = selectionState, selectedMedia = selectedMedia, toggleSelection = toggleSelection, @@ -67,14 +61,13 @@ fun TimelineScreen( albumId = albumId, handler = handler, expandedDropDown = expandedDropDown, - mediaState = mediaState, + mediaState = mediaState.value, selectedMedia = selectedMedia, selectionState = selectionState, navigate = navigate, navigateUp = navigateUp ) }, - 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/timeline/components/TimelineNavActions.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/components/TimelineNavActions.kt index d5112ea29..91ed431ce 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/components/TimelineNavActions.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/timeline/components/TimelineNavActions.kt @@ -16,23 +16,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R -import com.dot.gallery.core.MediaState +import com.dot.gallery.feature_node.domain.model.MediaState 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.components.OptionItem import com.dot.gallery.feature_node.presentation.common.components.OptionSheet import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @Composable @@ -40,13 +37,12 @@ fun TimelineNavActions( albumId: Long, handler: MediaHandleUseCase, expandedDropDown: MutableState, - mediaState: StateFlow, + mediaState: MediaState, selectedMedia: SnapshotStateList, selectionState: MutableState, navigate: (route: String) -> Unit, navigateUp: () -> Unit ) { - val state by mediaState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val result = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult(), @@ -75,7 +71,7 @@ fun TimelineNavActions( onClick = { selectionState.value = !selectionState.value if (selectionState.value) - selectedMedia.addAll(state.media) + selectedMedia.addAll(mediaState.media) else selectedMedia.clear() expandedDropDown.value = false @@ -91,7 +87,7 @@ fun TimelineNavActions( scope.launch { handler.trashMedia( result = result, - mediaList = state.media, + mediaList = mediaState.media, trash = true ) navigateUp() 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 70d0b9daf..b012cfd5f 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 @@ -9,33 +9,29 @@ import android.app.Activity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.dot.gallery.R -import com.dot.gallery.core.AlbumState +import com.dot.gallery.feature_node.domain.model.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.MediaState 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 @Composable fun TrashedGridScreen( paddingValues: PaddingValues, albumName: String = stringResource(id = R.string.trash), - vm: MediaViewModel, handler: MediaHandleUseCase, - mediaState: StateFlow, - albumState: StateFlow, + mediaState: State, + albumsState: State, selectionState: MutableState, selectedMedia: SnapshotStateList, toggleSelection: (Int) -> Unit, @@ -45,10 +41,9 @@ fun TrashedGridScreen( ) = MediaScreen( paddingValues = paddingValues, target = TARGET_TRASH, - vm = vm, albumName = albumName, handler = handler, - albumState = albumState, + albumsState = albumsState, mediaState = mediaState, selectionState = selectionState, selectedMedia = selectedMedia, @@ -57,9 +52,9 @@ fun TrashedGridScreen( enableStickyHeaders = false, navActionsContent = { _: MutableState, _: ActivityResultLauncher -> - TrashedNavActions(handler, mediaState, selectedMedia, selectionState) + TrashedNavActions(handler, mediaState.value, selectedMedia, selectionState) }, - emptyContent = { padding -> EmptyMedia(Modifier.fillMaxSize(), padding) }, + emptyContent = { EmptyTrash() }, 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 16191033d..10ba1caf6 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 @@ -66,9 +66,9 @@ import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogA import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.RESTORE import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.TRASH import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager import com.dot.gallery.feature_node.presentation.util.canBeTrashed import com.dot.gallery.feature_node.presentation.util.mediaPair +import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager import com.dot.gallery.ui.theme.Shapes import com.github.panpf.sketch.AsyncImage import kotlinx.coroutines.launch diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt index f4e7c0a98..41c9789d6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/trashed/components/TrashedNavActions.kt @@ -8,33 +8,33 @@ package com.dot.gallery.feature_node.presentation.trashed.components import android.app.Activity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dot.gallery.R -import com.dot.gallery.core.MediaState +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.feature_node.domain.model.MediaState 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.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.util.rememberIsMediaManager -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @Composable fun TrashedNavActions( handler: MediaHandleUseCase, - mediaState: StateFlow, + mediaState: MediaState, selectedMedia: SnapshotStateList, selectionState: MutableState, ) { - val state by mediaState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val isMediaManager = rememberIsMediaManager() val deleteSheetState = rememberAppBottomSheetState() @@ -54,73 +54,79 @@ fun TrashedNavActions( } } ) - if (state.media.isNotEmpty()) { - TextButton( - onClick = { - scope.launch { - if (isMediaManager) { - restoreSheetState.show() - } else { - handler.trashMedia( - result, - selectedMedia.ifEmpty { state.media }, - false - ) - } - } - } - ) { - Text( - text = stringResource(R.string.trash_restore), - color = MaterialTheme.colorScheme.primary - ) - } - if (selectionState.value) { + AnimatedVisibility( + visible = mediaState.media.isNotEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { + Row { TextButton( onClick = { scope.launch { if (isMediaManager) { - deleteSheetState.show() + restoreSheetState.show() } else { - handler.deleteMedia(result, selectedMedia) + handler.trashMedia( + result, + selectedMedia.ifEmpty { mediaState.media }, + false + ) } } } ) { Text( - text = stringResource(R.string.trash_delete), + text = stringResource(R.string.trash_restore), color = MaterialTheme.colorScheme.primary ) } - } else { - TextButton( - onClick = { - scope.launch { - if (isMediaManager) { - deleteSheetState.show() - } else { - handler.deleteMedia(result, state.media) + if (selectionState.value) { + TextButton( + onClick = { + scope.launch { + if (isMediaManager) { + deleteSheetState.show() + } else { + handler.deleteMedia(result, selectedMedia) + } } } + ) { + Text( + text = stringResource(R.string.trash_delete), + color = MaterialTheme.colorScheme.primary + ) + } + } else { + TextButton( + onClick = { + scope.launch { + if (isMediaManager) { + deleteSheetState.show() + } else { + handler.deleteMedia(result, mediaState.media) + } + } + } + ) { + Text( + text = stringResource(R.string.trash_empty), + color = MaterialTheme.colorScheme.primary + ) } - ) { - Text( - text = stringResource(R.string.trash_empty), - color = MaterialTheme.colorScheme.primary - ) } } } TrashDialog( appBottomSheetState = deleteSheetState, - data = selectedMedia.ifEmpty { state.media }, + data = selectedMedia.ifEmpty { mediaState.media }, action = TrashDialogAction.DELETE ) { handler.deleteMedia(result, it) } TrashDialog( appBottomSheetState = restoreSheetState, - data = selectedMedia.ifEmpty { state.media }, + data = selectedMedia.ifEmpty { mediaState.media }, action = TrashDialogAction.RESTORE ) { handler.trashMedia(result, it, false) 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 db94de5f5..f50a882e4 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 @@ -8,7 +8,6 @@ package com.dot.gallery.feature_node.presentation.trashed.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -38,7 +37,8 @@ import kotlinx.coroutines.launch @Stable @NonRestartableComposable @Composable -fun BoxScope.TrashedViewBottomBar( +fun TrashedViewBottomBar( + modifier: Modifier = Modifier, handler: MediaHandleUseCase, showUI: Boolean, paddingValues: PaddingValues, @@ -54,10 +54,9 @@ fun BoxScope.TrashedViewBottomBar( exit = Constants.Animation.exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter) ) { Row( - modifier = Modifier + modifier = modifier .background( Brush.verticalGradient( colors = listOf(Color.Transparent, BlackScrim) @@ -67,11 +66,11 @@ fun BoxScope.TrashedViewBottomBar( top = 24.dp, bottom = paddingValues.calculateBottomPadding() ) - .align(Alignment.BottomCenter), + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, ) { - // Favourite Component + // Restore Component BottomBarColumn( currentMedia = currentMedia, imageVector = Icons.Outlined.RestoreFromTrash, 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 d83c791d3..201ba6c21 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 @@ -18,23 +18,33 @@ import android.provider.MediaStore import android.provider.Settings import android.view.HapticFeedbackConstants import android.view.View +import android.view.Window +import android.view.WindowManager import android.view.accessibility.AccessibilityManager import android.widget.Toast import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf 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.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import com.dot.gallery.BuildConfig import com.dot.gallery.R import com.dot.gallery.core.Settings.Misc.allowVibrations +import com.dot.gallery.feature_node.data.data_source.InternalDatabase import com.dot.gallery.feature_node.domain.model.Media import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,6 +52,44 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Composable +fun getNavigationBarHeight(): Dp { + val insets = WindowInsets.navigationBars + val density = LocalDensity.current + return remember { with(density) { insets.getBottom(density).toDp() } } +} + +@Composable +fun SecureWindow(content: @Composable () -> Unit) { + ProvideWindowContext { + val window = LocalWindowContext.current + DisposableEffect(Unit) { + window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + content() + } +} + +val LocalWindowContext = compositionLocalOf { null } + +@Composable +fun ProvideWindowContext(content: @Composable () -> Unit) { + val context = LocalContext.current + val window = remember(context) { + (context as Activity).window + } + CompositionLocalProvider(LocalWindowContext provides window, content = content) +} + +val Context.mediaStoreVersion: String + get() = "${MediaStore.getGeneration(this, MediaStore.VOLUME_EXTERNAL_PRIMARY)}/${MediaStore.getVersion(this)}" + +suspend fun InternalDatabase.isMediaUpToDate(context: Context): Boolean { + return getMediaDao().isMediaVersionUpToDate(context.mediaStoreVersion) +} @Composable fun toastError(message: String? = null): Toast { @@ -76,16 +124,14 @@ class FeedbackManager(private val view: View, scope: CoroutineScope) { view.reallyPerformHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } } +} - companion object { - @Composable - fun rememberFeedbackManager(): FeedbackManager { - val view = LocalView.current - val scope = rememberCoroutineScope() - return remember(view, scope) { - FeedbackManager(view, scope) - } - } +@Composable +fun rememberFeedbackManager(): FeedbackManager { + val view = LocalView.current + val scope = rememberCoroutineScope() + return remember(view, scope) { + FeedbackManager(view, scope) } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ExifMetadata.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ExifMetadata.kt index b4662e0dd..54d4884a7 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ExifMetadata.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ExifMetadata.kt @@ -8,6 +8,7 @@ package com.dot.gallery.feature_node.presentation.util import androidx.exifinterface.media.ExifInterface import java.math.RoundingMode import java.text.DecimalFormat +import java.util.Locale class ExifMetadata(exifInterface: ExifInterface) { val manufacturerName: String? = @@ -50,8 +51,7 @@ class ExifMetadata(exifInterface: ExifInterface) { val formattedCords: String? get() = if (gpsLatLong != null) String.format( - "%.3f, %.3f", - gpsLatLong[0], gpsLatLong[1] + Locale.getDefault(), "%.3f, %.3f", gpsLatLong[0], gpsLatLong[1] ) else null } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt index 90eb60783..e10b3f484 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt @@ -24,8 +24,8 @@ import androidx.core.app.ShareCompat import androidx.exifinterface.media.ExifInterface import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.ImageFilter +import com.dot.gallery.feature_node.domain.model.InfoRow import com.dot.gallery.feature_node.domain.model.Media -import com.dot.gallery.feature_node.presentation.mediaview.components.InfoRow import com.dot.gallery.feature_node.presentation.mediaview.components.retrieveMetadata import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.UriByteDataHelper import jp.co.cyberagent.android.gpuimage.GPUImage @@ -497,7 +497,7 @@ fun List.writeRequest( ) = IntentSenderRequest.Builder(MediaStore.createWriteRequest(contentResolver, map { it.uri })).build() @Composable -fun rememberMediaInfo(media: Media, exifMetadata: ExifMetadata, onLabelClick: () -> Unit): List { +fun rememberMediaInfo(media: Media, exifMetadata: ExifMetadata?, onLabelClick: () -> Unit): List { val context = LocalContext.current return remember(media) { media.retrieveMetadata(context, exifMetadata, onLabelClick) @@ -505,9 +505,9 @@ fun rememberMediaInfo(media: Media, exifMetadata: ExifMetadata, onLabelClick: () } @Composable -fun rememberExifMetadata(media: Media, exifInterface: ExifInterface): ExifMetadata { - return remember(media) { - ExifMetadata(exifInterface) +fun rememberExifMetadata(media: Media, exifInterface: ExifInterface?): ExifMetadata? { + return remember(media, exifInterface) { + exifInterface?.let { ExifMetadata(it) } } } @@ -560,7 +560,7 @@ fun Context.shareMedia(media: EncryptedMedia) { ShareCompat .IntentBuilder(this) .setType(media.mimeType) - .addStream(UriByteDataHelper.getUri(media.bytes, media.duration != null)) + .addStream(UriByteDataHelper.getUri(this, media.bytes, media.fileExtension, media.duration != null)) .startChooser() } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/IntExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/IntExt.kt new file mode 100644 index 000000000..091240fbf --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/IntExt.kt @@ -0,0 +1,25 @@ +package com.dot.gallery.feature_node.presentation.util + +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Stable +fun Int.roundDpToPx(density: Density) = with(density) { dp.roundToPx() } + +@Stable +fun Int.roundSpToPx(density: Density) = with(density) { sp.roundToPx() } + +fun Float.normalize( + minValue: Float, + maxValue: Float = 1f, + minNormalizedValue: Float = 0f, + maxNormalizedValue: Float = 1f +): Float { + return ((this - minValue) / (maxValue - minValue)).coerceIn( + minNormalizedValue, + maxNormalizedValue + ) +} + diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/LogExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/LogExt.kt new file mode 100644 index 000000000..47c6e69aa --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/LogExt.kt @@ -0,0 +1,26 @@ +@file:Suppress("KotlinConstantConditions") + +package com.dot.gallery.feature_node.presentation.util + +import android.util.Log +import com.dot.gallery.BuildConfig + +fun printDebug(message: Any) { + printDebug(message.toString()) +} + +fun printDebug(message: String) { + if (BuildConfig.BUILD_TYPE != "release") { + Log.d("GalleryInfo", message) + } +} + +fun printError(message: String) { + Log.e("GalleryInfo", message) +} + +fun printWarning(message: String) { + if (BuildConfig.BUILD_TYPE != "release") { + Log.w("GalleryInfo", message) + } +} \ 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 5c34024c0..7139c4765 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 @@ -19,6 +19,8 @@ sealed class Screen(val route: String) { fun idAndTarget() = "$route?mediaId={mediaId}&target={target}" fun idAndAlbum() = "$route?mediaId={mediaId}&albumId={albumId}" + + fun idAndQuery() = "$route?mediaId={mediaId}&query={query}" } data object TrashedScreen : Screen("trashed_screen") @@ -31,11 +33,6 @@ sealed class Screen(val route: String) { data object SetupScreen: Screen("setup_screen") data object VaultScreen : Screen("vault_screen") - data object EncryptedMediaViewScreen : Screen("encrypted_media_view_screen") { - fun id() = "$route?mediaId={mediaId}" - - fun id(id: Long) = "$route?mediaId=$id" - } data object LibraryScreen : Screen("library_screen") diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt index 0b3af793f..5072558a0 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt @@ -13,20 +13,22 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.dot.gallery.core.Constants -import com.dot.gallery.core.EncryptedMediaState -import com.dot.gallery.core.MediaState import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem +import com.dot.gallery.feature_node.domain.model.EncryptedMediaState import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.MediaItem -import com.dot.gallery.feature_node.domain.use_case.MediaUseCases +import com.dot.gallery.feature_node.domain.model.MediaState +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,33 +45,47 @@ fun RepeatOnResume(action: () -> Unit) { } fun MutableState.update(newState: T) { - if (value != newState) { - value = newState - } + value = newState } -fun MediaUseCases.mediaFlowWithType( +fun MediaRepository.mediaFlowWithType( albumId: Long, allowedMedia: AllowedMedia ): Flow>> = (if (albumId != -1L) { - getMediaByAlbumWithTypeUseCase(albumId, allowedMedia) + getMediaByAlbumIdWithType(albumId, allowedMedia) } else { - getMediaByTypeUseCase(allowedMedia) + getMediaByType(allowedMedia) }).flowOn(Dispatchers.IO).conflate() -fun MediaUseCases.mediaFlow(albumId: Long, target: String?): Flow>> = +fun MediaRepository.mediaFlow(albumId: Long, target: String?): Flow>> = (if (albumId != -1L) { - getMediaByAlbumUseCase(albumId) + getMediaByAlbumId(albumId) } else if (!target.isNullOrEmpty()) { when (target) { - Constants.Target.TARGET_FAVORITES -> getMediaFavoriteUseCase() - Constants.Target.TARGET_TRASH -> getMediaTrashedUseCase() - else -> getMediaUseCase() + Constants.Target.TARGET_FAVORITES -> getFavorites(mediaOrder = MediaOrder.Default) + Constants.Target.TARGET_TRASH -> getTrashed() + else -> getMedia() } } else { - getMediaUseCase() - }).flowOn(Dispatchers.IO).conflate() + getMedia() + }) + +fun Flow>>.mapMedia( + albumId: Long, + groupByMonth: Boolean = false, + withMonthHeader: Boolean = true, + updateDatabase: () -> Unit +) = map { + updateDatabase() + mapMediaToItem( + data = it.data ?: emptyList(), + error = it.message ?: "", + albumId = albumId, + groupByMonth = groupByMonth, + withMonthHeader = withMonthHeader + ) +} suspend fun MutableStateFlow.collectMedia( data: List, @@ -77,71 +93,86 @@ suspend fun MutableStateFlow.collectMedia( albumId: Long, groupByMonth: Boolean = false, withMonthHeader: Boolean = true -) { - val timeStart = System.currentTimeMillis() +) = withContext(Dispatchers.IO) { + emit( + mapMediaToItem( + data = data, + error = error, + albumId = albumId, + groupByMonth = groupByMonth, + withMonthHeader = withMonthHeader + ) + ) +} + +suspend fun mapMediaToItem( + data: List, + error: String, + albumId: Long, + groupByMonth: Boolean = false, + withMonthHeader: Boolean = true +) = withContext(Dispatchers.IO) { val mappedData = mutableListOf() val mappedDataWithMonthly = mutableListOf() val monthHeaderList: MutableSet = mutableSetOf() - withContext(Dispatchers.IO) { - val groupedData = data.groupBy { - if (groupByMonth) { - it.timestamp.getMonth() - } else { - it.timestamp.getDate( - stringToday = "Today" - /** Localized in composition */ - , - stringYesterday = "Yesterday" - /** Localized in composition */ - ) - } + val headers = mutableListOf() + val groupedData = data.groupBy { + if (groupByMonth) { + it.timestamp.getMonth() + } else { + it.timestamp.getDate( + stringToday = "Today" + /** Localized in composition */ + , + stringYesterday = "Yesterday" + /** Localized in composition */ + ) + } + } + groupedData.forEach { (date, data) -> + val dateHeader = MediaItem.Header("header_$date", date, data.map { it.id }.toSet()) + headers.add(dateHeader) + val groupedMedia = data.map { + MediaItem.MediaViewItem("media_${it.id}_${it.label}", it) } - groupedData.forEach { (date, data) -> - val dateHeader = MediaItem.Header("header_$date", date, data) - val groupedMedia = data.map { - MediaItem.MediaViewItem("media_${it.id}_${it.label}", it) + if (groupByMonth) { + mappedData.add(dateHeader) + mappedData.addAll(groupedMedia) + mappedDataWithMonthly.add(dateHeader) + mappedDataWithMonthly.addAll(groupedMedia) + } else { + val month = getMonth(date) + if (month.isNotEmpty() && !monthHeaderList.contains(month)) { + monthHeaderList.add(month) + if (withMonthHeader && mappedDataWithMonthly.isNotEmpty()) { + mappedDataWithMonthly.add( + MediaItem.Header( + "header_big_${month}_${data.size}", + month, + data.map { it.id }.toSet() + ) + ) + } } - if (groupByMonth) { - mappedData.add(dateHeader) - mappedData.addAll(groupedMedia) + mappedData.add(dateHeader) + if (withMonthHeader) { mappedDataWithMonthly.add(dateHeader) + } + mappedData.addAll(groupedMedia) + if (withMonthHeader) { mappedDataWithMonthly.addAll(groupedMedia) - } else { - val month = getMonth(date) - if (month.isNotEmpty() && !monthHeaderList.contains(month)) { - monthHeaderList.add(month) - if (withMonthHeader && mappedDataWithMonthly.isNotEmpty()) { - mappedDataWithMonthly.add( - MediaItem.Header( - "header_big_${month}_${data.size}", - month, - emptyList() - ) - ) - } - } - mappedData.add(dateHeader) - if (withMonthHeader) { - mappedDataWithMonthly.add(dateHeader) - } - mappedData.addAll(groupedMedia) - if (withMonthHeader) { - mappedDataWithMonthly.addAll(groupedMedia) - } } } - println("-->Media mapping took: ${System.currentTimeMillis() - timeStart}ms") - emit( - MediaState( - isLoading = false, - error = error, - media = data, - mappedMedia = mappedData, - mappedMediaWithMonthly = if (withMonthHeader) mappedDataWithMonthly else emptyList(), - dateHeader = data.dateHeader(albumId) - ) - ) } + MediaState( + isLoading = false, + error = error, + media = data, + headers = headers, + mappedMedia = mappedData, + mappedMediaWithMonthly = if (withMonthHeader) mappedDataWithMonthly else emptyList(), + dateHeader = data.dateHeader(albumId) + ) } private fun List.dateHeader(albumId: Long): String = @@ -155,67 +186,78 @@ suspend fun MutableStateFlow.collectEncryptedMedia( data: List, groupByMonth: Boolean = false, withMonthHeader: Boolean = true -) { - val timeStart = System.currentTimeMillis() +) = withContext(Dispatchers.IO) { + emit( + mapEncryptedMediaToItem( + data = data, + groupByMonth = groupByMonth, + withMonthHeader = withMonthHeader + ) + ) +} + +suspend fun mapEncryptedMediaToItem( + data: List, + groupByMonth: Boolean = false, + withMonthHeader: Boolean = true +) = withContext(Dispatchers.IO) { val mappedData = mutableListOf() val mappedDataWithMonthly = mutableListOf() val monthHeaderList: MutableSet = mutableSetOf() - withContext(Dispatchers.IO) { - val groupedData = data.groupBy { - if (groupByMonth) { - it.timestamp.getMonth() - } else { - it.timestamp.getDate( - stringToday = "Today" - /** Localized in composition */ - , - stringYesterday = "Yesterday" - /** Localized in composition */ - ) - } + val headers = mutableListOf() + val groupedData = data.groupBy { + if (groupByMonth) { + it.timestamp.getMonth() + } else { + it.timestamp.getDate( + stringToday = "Today" + /** Localized in composition */ + , + stringYesterday = "Yesterday" + /** Localized in composition */ + ) } - groupedData.forEach { (date, data) -> - val dateHeader = EncryptedMediaItem.Header("header_$date", date, data) - val groupedMedia = data.map { - EncryptedMediaItem.MediaViewItem("media_${it.id}_${it.label}", it) + } + groupedData.forEach { (date, data) -> + val dateHeader = EncryptedMediaItem.Header("header_$date", date, data.map { it.id }.toSet()) + headers.add(dateHeader) + val groupedMedia = data.map { + EncryptedMediaItem.MediaViewItem("media_${it.id}_${it.label}", it) + } + if (groupByMonth) { + mappedData.add(dateHeader) + mappedData.addAll(groupedMedia) + mappedDataWithMonthly.add(dateHeader) + mappedDataWithMonthly.addAll(groupedMedia) + } else { + val month = getMonth(date) + if (month.isNotEmpty() && !monthHeaderList.contains(month)) { + monthHeaderList.add(month) + if (withMonthHeader && mappedDataWithMonthly.isNotEmpty()) { + mappedDataWithMonthly.add( + EncryptedMediaItem.Header( + "header_big_${month}_${data.size}", + month, + data.map { it.id }.toSet() + ) + ) + } } - if (groupByMonth) { - mappedData.add(dateHeader) - mappedData.addAll(groupedMedia) + mappedData.add(dateHeader) + if (withMonthHeader) { mappedDataWithMonthly.add(dateHeader) + } + mappedData.addAll(groupedMedia) + if (withMonthHeader) { mappedDataWithMonthly.addAll(groupedMedia) - } else { - val month = getMonth(date) - if (month.isNotEmpty() && !monthHeaderList.contains(month)) { - monthHeaderList.add(month) - if (withMonthHeader && mappedDataWithMonthly.isNotEmpty()) { - mappedDataWithMonthly.add( - EncryptedMediaItem.Header( - "header_big_${month}_${data.size}", - month, - emptyList() - ) - ) - } - } - mappedData.add(dateHeader) - if (withMonthHeader) { - mappedDataWithMonthly.add(dateHeader) - } - mappedData.addAll(groupedMedia) - if (withMonthHeader) { - mappedDataWithMonthly.addAll(groupedMedia) - } } } - println("-->Media mapping took: ${System.currentTimeMillis() - timeStart}ms") - emit( - EncryptedMediaState( - isLoading = false, - media = data, - mappedMedia = mappedData, - mappedMediaWithMonthly = if (withMonthHeader) mappedDataWithMonthly else emptyList(), - ) - ) } + EncryptedMediaState( + isLoading = false, + media = data, + headers = headers, + mappedMedia = mappedData, + mappedMediaWithMonthly = if (withMonthHeader) mappedDataWithMonthly else emptyList(), + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ViewScreenConstants.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ViewScreenConstants.kt new file mode 100644 index 000000000..4de730c09 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ViewScreenConstants.kt @@ -0,0 +1,19 @@ +package com.dot.gallery.feature_node.presentation.util + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composables.core.SheetDetent + +object ViewScreenConstants { + val BOTTOM_BAR_HEIGHT = 132.dp + val BOTTOM_BAR_HEIGHT_SLIM = 128.dp + + val ImageOnly = SheetDetent("imageOnly") { _, _ -> BOTTOM_BAR_HEIGHT } + + @Suppress("FunctionName") + fun FullyExpanded(setHeight: (Dp) -> Unit) = + SheetDetent("fully-expanded") { _, sheetHeight -> + setHeight(sheetHeight) + sheetHeight + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt index 59af6397f..018e4bdbb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt @@ -3,16 +3,13 @@ package com.dot.gallery.feature_node.presentation.vault import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -37,6 +34,9 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,7 +49,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.dokar.pinchzoomgrid.PinchZoomGridLayout import com.dokar.pinchzoomgrid.rememberPinchZoomGridState import com.dot.gallery.R @@ -58,10 +57,11 @@ import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.cellsList import com.dot.gallery.core.Settings.Misc.rememberGridSize import com.dot.gallery.core.presentation.components.EmptyMedia -import com.dot.gallery.core.presentation.components.Error -import com.dot.gallery.core.presentation.components.LoadingMedia +import com.dot.gallery.feature_node.domain.model.EncryptedMediaState +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.model.VaultState import com.dot.gallery.feature_node.presentation.picker.PickerActivityContract -import com.dot.gallery.feature_node.presentation.util.Screen import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState import com.dot.gallery.feature_node.presentation.vault.components.DeleteVaultSheet import com.dot.gallery.feature_node.presentation.vault.components.EncryptedMediaGridView @@ -75,21 +75,21 @@ import kotlinx.coroutines.launch fun VaultDisplay( navigateUp: () -> Unit, navigate: (route: String) -> Unit, + mediaState: State, + vaultState: VaultState, + currentVault: MutableState, onCreateVaultClick: () -> Unit, - vm: VaultViewModel + addMediaToVault: (Vault, Media) -> Unit, + setVault: (Vault) -> Unit, + deleteVault: (Vault) -> Unit ) { - val state by vm.mediaState.collectAsStateWithLifecycle() - val vaults by vm.vaults.collectAsStateWithLifecycle() - val currentVault by vm.currentVault.collectAsStateWithLifecycle() - LaunchedEffect(vaults) { - if (vaults.isNotEmpty()) { - vm.setVault(currentVault) { - //TODO: Add error handling - } + LaunchedEffect(vaultState, currentVault.value) { + currentVault.value?.let { setVault(it) } ?: run { + vaultState.vaults.firstOrNull()?.let { setVault(it) } } } - + var lastCellIndex by rememberGridSize() val pinchState = rememberPinchZoomGridState( @@ -110,208 +110,186 @@ fun VaultDisplay( val pickerLauncher = rememberLauncherForActivityResult(PickerActivityContract()) { mediaList -> mediaList.forEach { - vm.addMedia(it) + currentVault.value?.let { vault -> addMediaToVault(vault, it) } } } val scope = rememberCoroutineScope() val newVaultSheetState = rememberAppBottomSheetState() val deleteVaultSheetState = rememberAppBottomSheetState() - - Box { - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - floatingActionButton = { - FloatingActionButton( - onClick = { - pickerLauncher.launch(Unit) - } - ) { - Icon(imageVector = Icons.Outlined.Add, contentDescription = stringResource(R.string.add_media_to_vault_cd)) + + LaunchedEffect(vaultState) { + if (vaultState.isLoading) return@LaunchedEffect + if (vaultState.vaults.isNotEmpty()) return@LaunchedEffect + navigateUp() + } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + floatingActionButton = { + FloatingActionButton( + onClick = { + pickerLauncher.launch(Unit) } - }, - topBar = { - TopAppBar( - title = { - Column { - val sheetState = rememberAppBottomSheetState() - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .clickable { - scope.launch { - if (vaults.size > 1) { - sheetState.show() - } - } + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.add_media_to_vault_cd) + ) + } + }, + topBar = { + TopAppBar( + title = { + Column { + val sheetState = rememberAppBottomSheetState() + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable( + enabled = remember(vaultState) { + vaultState.vaults.size > 1 }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + scope.launch { + sheetState.show() + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = currentVault.value?.name ?: stringResource(R.string.unknown_vault) + ) + androidx.compose.animation.AnimatedVisibility( + visible = vaultState.vaults.size > 1, + enter = enterAnimation, + exit = exitAnimation ) { - Text( - text = currentVault?.name ?: stringResource(R.string.unknown_vault) + Icon( + modifier = Modifier + .size(24.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape + ), + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer ) - if (vaults.size > 1) { + } + } + SelectVaultSheet( + state = sheetState, + vaultState = vaultState + ) { vault -> + scope.launch { + setVault(vault) + } + } + } + }, + navigationIcon = { + IconButton(onClick = navigateUp) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back_cd) + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { + PinchZoomGridLayout(state = pinchState) { + EncryptedMediaGridView( + mediaState = mediaState, + allowSelection = true, + showSearchBar = false, + enableStickyHeaders = false, + paddingValues = it, + canScroll = canScroll, + showMonthlyHeader = false, + isScrolling = remember { mutableStateOf(false) }, + aboveGridContent = { + Column { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp) + .horizontalScroll(state = rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SuggestionChip( + onClick = { + scope.launch { + newVaultSheetState.show() + } + }, + label = { Text(text = stringResource(R.string.new_vault)) }, + border = null, + shape = CircleShape, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + iconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + icon = { Icon( - modifier = Modifier - .size(24.dp) - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape - ), - imageVector = Icons.Rounded.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer + imageVector = Icons.Outlined.Add, + contentDescription = null ) } - } - SelectVaultSheet( - state = sheetState, - vaults = vaults.filterNot { it == currentVault }, - onVaultSelected = { vault -> + ) + SuggestionChip( + onClick = { scope.launch { - vm.setVault(vault) {} + deleteVaultSheetState.show() } + }, + label = { Text(text = stringResource(R.string.delete_vault)) }, + border = null, + shape = CircleShape, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer, + iconContentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + icon = { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = null + ) } ) } - }, - navigationIcon = { - IconButton(onClick = navigateUp) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = R.string.back_cd) - ) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { - PinchZoomGridLayout(state = pinchState) { - AnimatedVisibility( - visible = state.isLoading, - enter = enterAnimation, - exit = exitAnimation - ) { - LoadingMedia( - paddingValues = PaddingValues( - top = it.calculateTopPadding() + 56.dp, - bottom = it.calculateBottomPadding() - ) + } + NewVaultSheet( + state = newVaultSheetState, + onConfirm = onCreateVaultClick ) - } - EncryptedMediaGridView( - mediaState = state, - allowSelection = true, - showSearchBar = false, - enableStickyHeaders = false, - paddingValues = it, - canScroll = canScroll, - showMonthlyHeader = false, - isScrolling = remember { mutableStateOf(false) }, - aboveGridContent = { - Column { - Row( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 12.dp) - .horizontalScroll(state = rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SuggestionChip( - onClick = { - scope.launch { - newVaultSheetState.show() - } - }, - label = { Text(text = stringResource(R.string.new_vault)) }, - border = null, - shape = CircleShape, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer, - iconContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - icon = { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null - ) - } - ) - SuggestionChip( - onClick = { - scope.launch { - deleteVaultSheetState.show() - } - }, - label = { Text(text = stringResource(R.string.delete_vault)) }, - border = null, - shape = CircleShape, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer, - iconContentColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - icon = { - Icon( - imageVector = Icons.Default.DeleteOutline, - contentDescription = null - ) - } - ) - } - val showError = remember(state) { state.error.isNotEmpty() } - AnimatedVisibility( - visible = showError, - modifier = Modifier - .padding(it) - .fillMaxWidth() - ) { - Error(errorMessage = state.error) - } - val showEmpty = - remember(state) { state.media.isEmpty() && !state.isLoading && !showError } - AnimatedVisibility( - visible = showEmpty, - modifier = Modifier - .padding(it) - .fillMaxWidth() - ) { - EmptyMedia() + DeleteVaultSheet( + state = deleteVaultSheetState, + onConfirm = { + val vault = currentVault.value ?: vaultState.vaults.firstOrNull() + vault?.let { it1 -> deleteVault(it1) } + if (vaultState.vaults.isEmpty()) { + navigateUp() } } - NewVaultSheet( - state = newVaultSheetState, - onConfirm = onCreateVaultClick - ) - DeleteVaultSheet( - state = deleteVaultSheetState, - onConfirm = { - vm.deleteVault(currentVault!!) - if (vaults.isEmpty()) { - navigateUp() - } - } - ) - }, - onMediaClick = { encryptedMedia -> - navigate(Screen.EncryptedMediaViewScreen.id(encryptedMedia.id)) - } - ) - } + ) + }, + onMediaClick = { encryptedMedia -> + navigate(VaultScreens.EncryptedMediaViewScreen.id(encryptedMedia.id)) + }, + emptyContent = { + EmptyMedia() + } + ) } - /*SelectionSheet( - modifier = Modifier - .align(Alignment.BottomEnd), - target = target, - selectedMedia = selectedMedia, - selectionState = selectionState, - albumsState = albumsState, - handler = handler - )*/ - } + } \ No newline at end of file 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 cb1e84f9c..2aa970f9a 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 @@ -1,114 +1,166 @@ package com.dot.gallery.feature_node.presentation.vault -import android.app.Activity import android.os.Build -import android.view.WindowManager import androidx.annotation.RequiresApi -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt.PromptInfo import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument 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.Animation.navigateInAnimation +import com.dot.gallery.core.Constants.Animation.navigateUpAnimation +import com.dot.gallery.feature_node.presentation.common.ChanneledViewModel +import com.dot.gallery.feature_node.presentation.util.SecureWindow +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.EncryptedMediaViewScreen import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable fun VaultScreen( + paddingValues: PaddingValues, + toggleRotate: () -> Unit, shouldSkipAuth: MutableState, navigateUp: () -> Unit, - navigate: (route: String) -> Unit, - vm: VaultViewModel -) { - val window = (LocalContext.current as Activity).window +) = SecureWindow { + val viewModel = hiltViewModel() + viewModel.attachToLifecycle() + val navController = rememberNavController() - DisposableEffect(Unit) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - onDispose { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } + val navPipe = hiltViewModel() + navPipe + .initWithNav(navController) + .collectAsStateWithLifecycle(LocalLifecycleOwner.current) - val vaults by vm.vaults.collectAsStateWithLifecycle() - val currentVault by vm.currentVault.collectAsStateWithLifecycle() + var addNewVault by remember { mutableStateOf(false) } var isAuthenticated by remember { mutableStateOf(shouldSkipAuth.value) } val biometricState = rememberBiometricState( + title = stringResource(R.string.biometric_authentication), + subtitle = stringResource(R.string.unlock_your_vault), onSuccess = { isAuthenticated = true + navPipe.navigate(VaultScreens.VaultDisplay()) }, onFailed = { isAuthenticated = false - navigateUp() - }, - biometricPromptInfo = PromptInfo.Builder() - .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) - .setTitle(stringResource(R.string.biometric_authentication)) - .setSubtitle(stringResource(R.string.unlock_your_vault)) - .build() + if (addNewVault) navController.navigateUp() else navigateUp() + } ) - var addNewVault by remember { mutableStateOf(false) } - val canAllowAccess = remember(biometricState) { - biometricState.canAllowAccess - } - LaunchedEffect(isAuthenticated, canAllowAccess, vaults) { - if (!isAuthenticated && !addNewVault && vaults.isNotEmpty()) { - if (canAllowAccess) { - biometricState.authenticate() - } else navigateUp() - } - } - if (isAuthenticated && canAllowAccess) { - vm.attachToLifecycle() + val vaultState by viewModel.vaultState.collectAsStateWithLifecycle() + val startDestination by remember(vaultState) { + derivedStateOf { vaultState.getStartScreen() } } - AnimatedVisibility( - visible = addNewVault || vaults.isEmpty(), - enter = enterAnimation, - exit = exitAnimation + NavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + startDestination = startDestination, + enterTransition = { navigateInAnimation }, + exitTransition = { navigateUpAnimation }, + popEnterTransition = { navigateInAnimation }, + popExitTransition = { navigateUpAnimation } ) { - VaultSetup( - navigateUp = { - if (addNewVault) { + composable(VaultScreens.LoadingScreen()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + composable(VaultScreens.VaultSetup()) { + VaultSetup( + navigateUp = { + if (addNewVault) { + addNewVault = false + if (vaultState.vaults.isEmpty()) navigateUp() else navPipe.navigateUp() + } else { + navigateUp() + } + }, + onCreate = { addNewVault = false - if (vaults.isEmpty()) navigateUp() - } else { - navigateUp() + isAuthenticated = false + biometricState.authenticate() + }, + vm = viewModel + ) + } + composable(VaultScreens.VaultDisplay()) { + LaunchedEffect(isAuthenticated, biometricState.isSupported, vaultState) { + if (!isAuthenticated && !addNewVault && vaultState.vaults.isNotEmpty()) { + if (biometricState.isSupported) { + biometricState.authenticate() + } else navigateUp() } - }, - onCreate = { - addNewVault = false - isAuthenticated = false - biometricState.authenticate() - }, - vm = vm - ) - } + } + val mediaState = viewModel.mediaState.collectAsStateWithLifecycle() - AnimatedVisibility( - visible = currentVault != null && !addNewVault && isAuthenticated, - enter = enterAnimation, - exit = exitAnimation - ) { - VaultDisplay( - navigateUp = navigateUp, - navigate = navigate, - onCreateVaultClick = { addNewVault = true }, - vm = vm - ) + AnimatedVisibility( + visible = isAuthenticated, + enter = enterAnimation, + exit = exitAnimation + ) { + VaultDisplay( + navigateUp = navigateUp, + navigate = navPipe::navigate, + vaultState = vaultState, + mediaState = mediaState, + currentVault = viewModel.currentVault, + addMediaToVault = viewModel::addMedia, + deleteVault = viewModel::deleteVault, + setVault = { vault -> viewModel.setVault(vault) {} }, + onCreateVaultClick = { + addNewVault = true + navPipe.navigate(VaultScreens.VaultSetup()) + } + ) + } + } + + composable( + route = VaultScreens.EncryptedMediaViewScreen.id(), + arguments = listOf( + navArgument("mediaId") { + type = NavType.LongType + } + ) + ) { backStackEntry -> + val mediaId = remember(backStackEntry) { + backStackEntry.arguments?.getLong("mediaId") + } + EncryptedMediaViewScreen( + navigateUp = navPipe::navigateUp, + toggleRotate = toggleRotate, + paddingValues = paddingValues, + mediaId = remember(mediaId) { mediaId ?: -1 }, + mediaState = viewModel.mediaState, + currentVault = viewModel.currentVault, + restoreMedia = viewModel::restoreMedia, + deleteMedia = viewModel::deleteMedia + ) + } } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreens.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreens.kt new file mode 100644 index 000000000..822a8d727 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreens.kt @@ -0,0 +1,16 @@ +package com.dot.gallery.feature_node.presentation.vault + +sealed class VaultScreens(val route: String) { + data object VaultSetup : VaultScreens("vault_setup") + data object VaultDisplay : VaultScreens("vault_display") + + data object EncryptedMediaViewScreen : VaultScreens("vault_media_view_screen") { + fun id() = "$route?mediaId={mediaId}" + + fun id(id: Long) = "$route?mediaId=$id" + } + + data object LoadingScreen : VaultScreens("vault_loading_screen") + + operator fun invoke() = route +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt index dd12fd3a1..86d6fc006 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt @@ -8,34 +8,28 @@ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.compose.animation.AnimatedVisibility 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.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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 import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.presentation.util.printError import com.dot.gallery.ui.core.Icons import com.dot.gallery.ui.core.icons.Encrypted @@ -55,94 +49,59 @@ fun VaultSetup( val isBiometricAvailable = remember { biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS } - - - Scaffold( + SetupWizard( + icon = Icons.Encrypted, + title = stringResource(R.string.vault_setup_title), + subtitle = stringResource(R.string.vault_setup_subtitle), bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 24.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedButton(onClick = navigateUp) { - Text(text = stringResource(id = R.string.action_cancel)) - } - Button( - onClick = { - vm.setVault(newVault) { - println("Error: $it") - nameError = it - } - if (nameError.isEmpty()) { + OutlinedButton(onClick = navigateUp) { + Text(text = stringResource(id = R.string.action_cancel)) + } + Button( + onClick = { + vm.setVault( + vault = newVault, + onFailed = { + val newError = if (it.contains("Already exists")) { + context.getString(R.string.vault_already_exists, newVault.name) + } else it + printError("Error: $newError") + nameError = newError + }, + onSuccess = { onCreate() } - }, - enabled = isBiometricAvailable && nameError.isEmpty() && newVault.name.isNotEmpty() - ) { - Text(text = stringResource(id = R.string.get_started)) - } - } - } - ) { - Column( - modifier = Modifier - .padding(it) - .padding(horizontal = 24.dp) - .padding(top = 24.dp), - verticalArrangement = Arrangement.spacedBy(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Encrypted, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally + ) + }, + enabled = isBiometricAvailable && nameError.isEmpty() && newVault.name.isNotEmpty() ) { - Text( - text = "Setup your vault", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Encrypt your most private photos & videos", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text(text = stringResource(id = R.string.get_started)) } - + }, + content = { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = newVault.name, onValueChange = { newName -> - newVault = newVault.copy(name = newName) + nameError = "" + newVault = newVault.copy(name = newName.filter { it.isLetterOrDigit() }) }, - label = { Text(text = "Vault Name") }, + label = { Text(text = stringResource(R.string.vault_setup_name)) }, + singleLine = true, isError = nameError.isNotEmpty(), enabled = isBiometricAvailable ) - AnimatedVisibility(visible = nameError.isNotEmpty()) { - Text( - text = nameError, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } AnimatedVisibility(visible = !isBiometricAvailable) { Text( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .background( color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp) ) .padding(16.dp), - text = "Please set-up a phone security measure before setting up the vault.", + text = stringResource(R.string.vault_setup_security_error), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center @@ -152,19 +111,27 @@ fun VaultSetup( AnimatedVisibility(visible = isBiometricAvailable) { Text( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .background( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp) ) .padding(16.dp), - text = "The vault will be accessed using your phone security measures (password or biometrics)\n\n" + - "Encryption key can be accessed inside the vault and will be used in order to restore any vaults.", + text = stringResource(R.string.vault_setup_summary), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) } + + AnimatedVisibility(visible = nameError.isNotEmpty()) { + Text( + text = nameError, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } } - } + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt index fe821bde7..ba6684f8d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt @@ -1,115 +1,113 @@ -@file:Suppress("LABEL_NAME_CLASH") - package com.dot.gallery.feature_node.presentation.vault import android.annotation.SuppressLint -import android.content.ContentResolver -import android.net.Uri import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dot.gallery.core.EncryptedMediaState +import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.EncryptedMediaState import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.Vault -import com.dot.gallery.feature_node.domain.use_case.VaultUseCases -import com.dot.gallery.feature_node.presentation.util.RepeatOnResume +import com.dot.gallery.feature_node.domain.model.VaultState +import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.presentation.util.collectEncryptedMedia +import com.dot.gallery.feature_node.presentation.util.printError import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.singleOrNull import kotlinx.coroutines.launch -import java.io.ByteArrayOutputStream +import kotlinx.coroutines.withContext import java.io.IOException import javax.inject.Inject @RequiresApi(Build.VERSION_CODES.TIRAMISU) @HiltViewModel open class VaultViewModel @Inject constructor( - private val contentResolver: ContentResolver, - private val vaultUseCases: VaultUseCases + private val repository: MediaRepository ) : ViewModel() { - private var _currentVault = MutableStateFlow(null) - val currentVault = _currentVault.asStateFlow() + var currentVault = mutableStateOf(null) private val _mediaState = MutableStateFlow(EncryptedMediaState()) val mediaState = _mediaState.asStateFlow() - private val _vaults = MutableStateFlow>(emptyList()) - val vaults = _vaults.asStateFlow() + private val _vaultState = MutableStateFlow(VaultState()) + val vaultState = _vaultState.asStateFlow() + + @SuppressLint("ComposableNaming") + @Composable + fun attachToLifecycle() { + LaunchedEffect(Unit) { + fetchVaultsAndMedia() + } + } init { - initVaults() + fetchVaultsAndMedia() } - fun setVault(vault: Vault?, onFailed: (reason: String) -> Unit) { - viewModelScope.launch { - vaultUseCases.getVaults().collectLatest { vaults -> - if (vault == null) { - getMedia(null) - return@collectLatest - } - val hasVault = vaults.find { it.uuid == vault.uuid } != null - if (hasVault) { - getMedia(vault) - } else { - vaultUseCases.createVault( - vault = vault, - onSuccess = { - getMedia(vault) - }, - onFailed = onFailed - ) + fun setVault(vault: Vault?, onFailed: (reason: String) -> Unit = {}, onSuccess: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val newVaultState = repository.getVaults().singleOrNull().mapToVaultState() + _vaultState.emit(newVaultState) + } + viewModelScope.launch(Dispatchers.IO) { + currentVault.value = vault + if (vault == null) { + fetchVaultsAndMedia() + withContext(Dispatchers.Main.immediate) { onSuccess() } + return@launch + } + val hasVault = _vaultState.value.vaults.find { it.uuid == vault.uuid } != null + if (hasVault) { + fetchVaultsAndMedia(vault) + withContext(Dispatchers.Main.immediate) { onSuccess() } + } else { + if (_vaultState.value.vaults.firstOrNull { it.name == vault.name } != null) { + onFailed("Already exists") + return@launch } + repository.createVault( + vault = vault, + onSuccess = { + fetchVaultsAndMedia(vault) + currentVault.value = vault + viewModelScope.launch(Dispatchers.Main.immediate) { onSuccess() } + }, + onFailed = onFailed + ) } } } fun deleteVault(vault: Vault) { viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.deleteVault( + repository.deleteVault( vault = vault, onSuccess = { - getMedia(null) + fetchVaultsAndMedia() }, onFailed = { - getMedia(null) + printError("Failed to delete vault: $it") + fetchVaultsAndMedia(vault) } ) } } - /** - * Attach the [VaultViewModel] to the lifecycle of the composable. - * This will start watching the directory for changes and update the media state accordingly. - * It will also fetch the media when the composable is resumed. - * This should be called in the composable that will use the [VaultViewModel]. - * - * Call only after Biometric Auth is successful. - */ - @SuppressLint("ComposableNaming") - @Composable - fun attachToLifecycle() { - LaunchedEffect(Unit) { - getMedia(_currentVault.value) - } - } - - fun addMedia(media: Media) { + fun addMedia(vault: Vault, media: Media) { viewModelScope.launch(Dispatchers.IO) { try { - _currentVault.value?.let { vault -> - getBytes(media.uri)?.let { - vaultUseCases.addMedia(vault, media) - } ?: return@let - getMedia(vault) - } + repository.addMedia(vault, media) + fetchVaultsAndMedia(vault) } catch (e: IOException) { e.printStackTrace() } @@ -118,81 +116,58 @@ open class VaultViewModel @Inject constructor( fun restoreMedia(vault: Vault, media: EncryptedMedia) { viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.restoreMedia(vault, media) - getMedia(vault) + repository.restoreMedia(vault, media) + fetchVaultsAndMedia(vault) } } fun deleteMedia(vault: Vault, media: EncryptedMedia) { viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.deleteEncryptedMedia(vault, media) - getMedia(vault) + repository.deleteEncryptedMedia(vault, media) + fetchVaultsAndMedia(vault) } } fun deleteAllMedia(vault: Vault) { viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.deleteAllEncryptedMedia( + repository.deleteAllEncryptedMedia( vault = vault, onSuccess = { - getMedia(vault) + fetchVaultsAndMedia(vault) }, onFailed = { failedFiles -> + printError("Failed to delete files: $failedFiles") // TODO: Handle failed files } ) } } - private fun initVaults() { + private fun fetchVaultsAndMedia(vault: Vault? = null) { + currentVault.value = vault viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.getVaults().collectLatest { vaults -> - _vaults.emit(vaults) - } - } - } - - @Throws(IOException::class) - private fun getBytes(uri: Uri): ByteArray? = - contentResolver.openInputStream(uri)?.use { inputStream -> - val byteBuffer = ByteArrayOutputStream() - val bufferSize = 1024 - val buffer = ByteArray(bufferSize) - - var len: Int - while (inputStream.read(buffer).also { len = it } != -1) { - byteBuffer.write(buffer, 0, len) + repository.getVaults().collectLatest { resource -> + _vaultState.emit(resource.mapToVaultState()) } - byteBuffer.toByteArray() } - - - private fun getMedia(vault: Vault?) { - viewModelScope.launch(Dispatchers.IO) { - vaultUseCases.getVaults().collectLatest { vaults -> - _vaults.emit(vaults) - if (vaults.isEmpty()) { - _mediaState.emit(EncryptedMediaState(isLoading = false, error = "No vaults found")) - return@collectLatest - } - _currentVault.emit(vault ?: vaults.first()) - vaultUseCases.getEncryptedMedia(_currentVault.value!!).collectLatest { - val data = it.data ?: emptyList() - if (_mediaState.value.media == data) { - if (data.isEmpty()) { - _mediaState.emit(EncryptedMediaState(isLoading = false)) - } - return@collectLatest - } - _mediaState.collectEncryptedMedia(data = data) + vault?.let { + viewModelScope.launch(Dispatchers.IO) { + repository.getEncryptedMedia(vault).collectLatest { + _mediaState.collectEncryptedMedia(data = it.data ?: emptyList()) } } } } + private fun Resource>?.mapToVaultState(): VaultState { + return VaultState( + isLoading = false, + vaults = (this?.data) ?: emptyList() + ) + } + override fun onCleared() { _mediaState.value = EncryptedMediaState() - _currentVault.value = null super.onCleared() } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGrid.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGrid.kt new file mode 100644 index 000000000..81b5b0668 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGrid.kt @@ -0,0 +1,358 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisallowComposableCalls +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +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.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.dokar.pinchzoomgrid.PinchZoomGridScope +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.feature_node.domain.model.EncryptedMediaState +import com.dot.gallery.core.presentation.components.Error +import com.dot.gallery.core.presentation.components.LoadingMedia +import com.dot.gallery.core.presentation.components.MediaItemHeader +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem +import com.dot.gallery.feature_node.domain.model.isBigHeaderKey +import com.dot.gallery.feature_node.domain.model.isHeaderKey +import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.update +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@Composable +fun PinchZoomGridScope.EncryptedMediaGrid( + gridState: LazyGridState, + gridCells: GridCells, + mediaState: State, + mappedData: SnapshotStateList, + paddingValues: PaddingValues, + allowSelection: Boolean, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + toggleSelection: @DisallowComposableCalls (Int) -> Unit, + canScroll: Boolean, + allowHeaders: Boolean, + aboveGridContent: @Composable (() -> Unit)?, + isScrolling: MutableState, + emptyContent: @Composable () -> Unit, + onMediaClick: @DisallowComposableCalls (media: EncryptedMedia) -> Unit +) { + LaunchedEffect(gridState.isScrollInProgress) { + snapshotFlow { + gridState.isScrollInProgress + }.collectLatest { + isScrolling.value = it + } + } + + val topContent: LazyGridScope.() -> Unit = remember(aboveGridContent) { + { + if (aboveGridContent != null) { + item( + span = { GridItemSpan(maxLineSpan) }, + key = "aboveGrid" + ) { + aboveGridContent.invoke() + } + } + } + } + val bottomContent: LazyGridScope.() -> Unit = remember { + { + item( + span = { GridItemSpan(maxLineSpan) }, + key = "loading" + ) { + AnimatedVisibility( + visible = mediaState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + LoadingMedia() + } + } + + item( + span = { GridItemSpan(maxLineSpan) }, + key = "empty" + ) { + AnimatedVisibility( + visible = mediaState.value.media.isEmpty() && !mediaState.value.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + emptyContent() + } + } + item( + span = { GridItemSpan(maxLineSpan) }, + key = "error" + ) { + AnimatedVisibility(visible = mediaState.value.error.isNotEmpty()) { + Error(errorMessage = mediaState.value.error) + } + } + } + } + + AnimatedVisibility( + visible = allowHeaders + ) { + EncryptedMediaGridContentWithHeaders( + gridCells = gridCells, + mediaState = mediaState, + mappedData = mappedData, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + onMediaClick = onMediaClick, + topContent = topContent, + bottomContent = bottomContent + ) + } + + AnimatedVisibility( + visible = !allowHeaders + ) { + EncryptedMediaGridContent( + gridCells = gridCells, + mediaState = mediaState, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + onMediaClick = onMediaClick, + topContent = topContent, + bottomContent = bottomContent + ) + } + +} + +@Composable +private fun PinchZoomGridScope.EncryptedMediaGridContentWithHeaders( + gridCells: GridCells, + mediaState: State, + mappedData: SnapshotStateList, + paddingValues: PaddingValues, + allowSelection: Boolean, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + toggleSelection: @DisallowComposableCalls (Int) -> Unit, + canScroll: Boolean, + onMediaClick: @DisallowComposableCalls (media: EncryptedMedia) -> Unit, + topContent: LazyGridScope.() -> Unit, + bottomContent: LazyGridScope.() -> Unit +) { + val scope = rememberCoroutineScope() + val stringToday = stringResource(id = R.string.header_today) + val stringYesterday = stringResource(id = R.string.header_yesterday) + val feedbackManager = rememberFeedbackManager() + EncryptedTimelineScroller( + modifier = Modifier + .padding(paddingValues) + .padding(top = 32.dp) + .padding(vertical = 32.dp), + mappedData = mappedData, + headers = remember(mediaState.value) { + mediaState.value.headers.toMutableStateList() + }, + state = gridState, + ) { + LazyVerticalGrid( + state = gridState, + modifier = Modifier.fillMaxSize(), + columns = gridCells, + contentPadding = paddingValues, + userScrollEnabled = canScroll, + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + topContent() + + items( + items = mappedData, + key = { item -> item.key }, + contentType = { item -> item.key.startsWith("media_") }, + span = { item -> + GridItemSpan(if (item.key.isHeaderKey) maxLineSpan else 1) + } + ) { it -> + if (it is EncryptedMediaItem.Header) { + val isChecked = rememberSaveable { mutableStateOf(false) } + if (allowSelection) { + LaunchedEffect(selectionState.value) { + // Uncheck if selectionState is set to false + isChecked.value = isChecked.value && selectionState.value + } + LaunchedEffect(selectedMedia.size) { + // Partial check of media items should not check the header + isChecked.value = selectedMedia.map { it.id }.containsAll(it.data) + } + } + MediaItemHeader( + modifier = Modifier + .animateItem( + fadeInSpec = null + ) + .pinchItem(key = it.key), + date = remember { + it.text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + }, + showAsBig = remember { it.key.isBigHeaderKey }, + isCheckVisible = selectionState, + isChecked = isChecked + ) { + if (allowSelection) { + feedbackManager.vibrate() + scope.launch { + isChecked.value = !isChecked.value + if (isChecked.value) { + val toAdd = it.data.toMutableList().apply { + // Avoid media from being added twice to selection + removeIf { + selectedMedia.map { media -> media.id }.contains(it) + } + } + selectedMedia.addAll(mediaState.value.media.filter { + toAdd.contains( + it.id + ) + }) + } else selectedMedia.removeAll { media -> it.data.contains(media.id) } + selectionState.update(selectedMedia.isNotEmpty()) + } + } + } + } else if (it is EncryptedMediaItem.MediaViewItem) { + EncryptedMediaImage( + modifier = Modifier + .animateItem( + fadeInSpec = null + ) + .pinchItem(key = it.key), + media = it.media, + selectionState = selectionState, + selectedMedia = selectedMedia, + canClick = canScroll, + onItemClick = { + if (selectionState.value && allowSelection) { + feedbackManager.vibrate() + toggleSelection(mediaState.value.media.indexOf(it)) + } else onMediaClick(it) + } + ) { + if (allowSelection) { + feedbackManager.vibrate() + toggleSelection(mediaState.value.media.indexOf(it)) + } + } + } + } + + + bottomContent() + } + } + + /*TimelineScroller2( + gridState = gridState, + mappedData = mappedData, + headerList = remember(mediaState.value) { + mediaState.value.headers.toMutableStateList() + }, + paddingValues = paddingValues + )*/ +} + +@Composable +private fun PinchZoomGridScope.EncryptedMediaGridContent( + gridCells: GridCells, + mediaState: State, + paddingValues: PaddingValues, + allowSelection: Boolean, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + toggleSelection: @DisallowComposableCalls (Int) -> Unit, + canScroll: Boolean, + onMediaClick: @DisallowComposableCalls (media: EncryptedMedia) -> Unit, + topContent: LazyGridScope.() -> Unit, + bottomContent: LazyGridScope.() -> Unit +) { + val feedbackManager = rememberFeedbackManager() + LazyVerticalGrid( + state = gridState, + modifier = Modifier.fillMaxSize(), + columns = gridCells, + contentPadding = paddingValues, + userScrollEnabled = canScroll, + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + topContent() + + itemsIndexed( + items = mediaState.value.media, + key = { _, item -> item.toString() }, + contentType = { _, item -> item.isImage } + ) { index, media -> + EncryptedMediaImage( + modifier = Modifier + .animateItem( + fadeInSpec = null + ) + .pinchItem(key = media.toString()), + media = media, + selectionState = selectionState, + selectedMedia = selectedMedia, + canClick = canScroll, + onItemClick = { + if (selectionState.value && allowSelection) { + feedbackManager.vibrate() + toggleSelection(index) + } else onMediaClick(it) + }, + onItemLongClick = { + if (allowSelection) { + feedbackManager.vibrate() + toggleSelection(index) + } + } + ) + } + + bottomContent() + } +} \ No newline at end of file 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 f6c69c80e..496c2a997 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 @@ -8,89 +8,90 @@ package com.dot.gallery.feature_node.presentation.vault.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 import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets 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.LazyGridItemInfo -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf 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.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.dokar.pinchzoomgrid.PinchZoomGridScope -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.EncryptedMediaState -import com.dot.gallery.core.presentation.components.StickyHeader -import com.dot.gallery.core.presentation.components.util.StickyHeaderGrid +import com.dot.gallery.core.Settings.Misc.rememberAutoHideSearchBar import com.dot.gallery.feature_node.domain.model.EncryptedMedia -import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem -import com.dot.gallery.feature_node.domain.model.isBigHeaderKey +import com.dot.gallery.feature_node.domain.model.EncryptedMediaState import com.dot.gallery.feature_node.domain.model.isHeaderKey import com.dot.gallery.feature_node.domain.model.isIgnoredKey -import com.dot.gallery.feature_node.presentation.common.components.rememberHeaderOffset -import com.dot.gallery.feature_node.presentation.util.FeedbackManager -import com.dot.gallery.feature_node.presentation.util.update -import kotlinx.coroutines.launch +import com.dot.gallery.feature_node.presentation.common.components.StickyHeaderGrid +import com.dot.gallery.feature_node.presentation.util.roundDpToPx +import com.dot.gallery.feature_node.presentation.util.roundSpToPx +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PinchZoomGridScope.EncryptedMediaGridView( - mediaState: EncryptedMediaState, + mediaState: State, paddingValues: PaddingValues = PaddingValues(0.dp), searchBarPaddingTop: Dp = 0.dp, - showSearchBar: Boolean = false, - allowSelection: Boolean = false, + showSearchBar: Boolean = remember { false }, + allowSelection: Boolean = remember { false }, selectionState: MutableState = remember { mutableStateOf(false) }, selectedMedia: SnapshotStateList = remember { mutableStateListOf() }, - toggleSelection: (Int) -> Unit = {}, + toggleSelection: @DisallowComposableCalls (Int) -> Unit = {}, canScroll: Boolean = true, - allowHeaders: Boolean = remember { true }, + allowHeaders: Boolean = true, enableStickyHeaders: Boolean = false, showMonthlyHeader: Boolean = false, aboveGridContent: @Composable (() -> Unit)? = null, isScrolling: MutableState, - onMediaClick: (media: EncryptedMedia) -> Unit = {} + emptyContent: @Composable () -> Unit, + onMediaClick: @DisallowComposableCalls (media: EncryptedMedia) -> Unit = {}, ) { - val stringToday = stringResource(id = R.string.header_today) - val stringYesterday = stringResource(id = R.string.header_yesterday) + val mappedData by remember(mediaState.value, showMonthlyHeader) { + derivedStateOf { + (if (showMonthlyHeader) mediaState.value.mappedMediaWithMonthly + else mediaState.value.mappedMedia).toMutableStateList() + } + } - val scope = rememberCoroutineScope() - val mappedData = remember(showMonthlyHeader, mediaState) { - if (showMonthlyHeader) mediaState.mappedMediaWithMonthly - else mediaState.mappedMedia + LaunchedEffect(showMonthlyHeader, mediaState.value) { + snapshotFlow { mediaState.value } + .distinctUntilChanged() + .collectLatest { + mappedData.clear() + mappedData.addAll( + if (showMonthlyHeader) mediaState.value.mappedMediaWithMonthly + else mediaState.value.mappedMedia + ) + } } - /** Selection state handling **/ BackHandler( enabled = selectionState.value && allowSelection, onBack = { @@ -98,188 +99,42 @@ fun PinchZoomGridScope.EncryptedMediaGridView( selectedMedia.clear() } ) - /** ************ **/ - - val feedbackManager = FeedbackManager.rememberFeedbackManager() - - @Composable - fun mediaGrid() { - LaunchedEffect(gridState.isScrollInProgress) { - isScrolling.value = gridState.isScrollInProgress - } - Box { - LazyVerticalGrid( - state = gridState, - modifier = Modifier.fillMaxSize(), - columns = gridCells, - contentPadding = paddingValues, - userScrollEnabled = canScroll, - horizontalArrangement = Arrangement.spacedBy(1.dp), - verticalArrangement = Arrangement.spacedBy(1.dp) - ) { - if (aboveGridContent != null) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "aboveGrid" - ) { - aboveGridContent.invoke() - } - } - if (allowHeaders) { - items( - items = mappedData, - key = { item -> item.key }, - contentType = { item -> item.key.startsWith("media_") }, - span = { item -> - GridItemSpan(if (item.key.isHeaderKey) maxLineSpan else 1) - } - ) { it -> - val item = remember { - if (it is EncryptedMediaItem.Header) it - else it as EncryptedMediaItem.MediaViewItem - } - if (item is EncryptedMediaItem.Header) { - val isChecked = rememberSaveable { mutableStateOf(false) } - if (allowSelection) { - LaunchedEffect(selectionState.value) { - // Uncheck if selectionState is set to false - isChecked.value = isChecked.value && selectionState.value - } - LaunchedEffect(selectedMedia.size) { - // Partial check of media items should not check the header - isChecked.value = selectedMedia.containsAll(item.data) - } - } - StickyHeader( - modifier = Modifier.pinchItem(key = it.key), - date = remember { - item.text - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - }, - showAsBig = remember { item.key.isBigHeaderKey }, - isCheckVisible = selectionState, - isChecked = isChecked - ) { - if (allowSelection) { - feedbackManager.vibrate() - scope.launch { - isChecked.value = !isChecked.value - if (isChecked.value) { - val toAdd = item.data.toMutableList().apply { - // Avoid media from being added twice to selection - removeIf { selectedMedia.contains(it) } - } - selectedMedia.addAll(toAdd) - } else selectedMedia.removeAll(item.data) - selectionState.update(selectedMedia.isNotEmpty()) - } - } - } - } else { - EncryptedMediaImage( - modifier = Modifier - .animateItem() - .pinchItem(key = it.key), - media = (item as EncryptedMediaItem.MediaViewItem).media, - selectionState = selectionState, - selectedMedia = selectedMedia, - canClick = canScroll, - onItemClick = { - if (selectionState.value && allowSelection) { - feedbackManager.vibrate() - toggleSelection(mediaState.media.indexOf(it)) - } else onMediaClick(it) - } - ) { - if (allowSelection) { - feedbackManager.vibrate() - toggleSelection(mediaState.media.indexOf(it)) - } - } - } - } - } else { - itemsIndexed( - items = mediaState.media, - key = { _, item -> item.toString() }, - contentType = { _, item -> item.isImage } - ) { index, media -> - EncryptedMediaImage( - modifier = Modifier - .animateItem() - .pinchItem(key = media.toString()), - media = media, - selectionState = selectionState, - selectedMedia = selectedMedia, - canClick = canScroll, - onItemClick = { - if (selectionState.value && allowSelection) { - feedbackManager.vibrate() - toggleSelection(index) - } else onMediaClick(it) - }, - onItemLongClick = { - if (allowSelection) { - feedbackManager.vibrate() - toggleSelection(index) - } - } - ) - } + /** + * Workaround for a small bug + * That shows the grid at the bottom after content is loaded + */ + val lastLoadingState by remember { mutableStateOf(mediaState.value.isLoading) } + LaunchedEffect(gridState, mediaState.value) { + snapshotFlow { mediaState.value.isLoading } + .distinctUntilChanged() + .collectLatest { isLoading -> + if (!isLoading && lastLoadingState) { + gridState.scrollToItem(0) } } - - if (allowHeaders) { - EncryptedTimelineScroller( - gridState = gridState, - mappedData = mappedData, - paddingValues = paddingValues - ) - } - } } - if (enableStickyHeaders) { - /** - * Remember last known header item - */ - val stickyHeaderLastItem = remember { mutableStateOf(null) } - - val headers = remember(mappedData) { - mappedData.filterIsInstance().filter { !it.key.isBigHeaderKey } - } - - val stickyHeaderItem by remember(mappedData) { - derivedStateOf { - val firstItem = gridState.layoutInfo.visibleItemsInfo.firstOrNull() - val firstHeaderIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull { - it.key.isHeaderKey && !it.key.toString().contains("big") - }?.index + AnimatedVisibility( + visible = enableStickyHeaders + ) { + val stickyHeaderItem by rememberEncryptedStickyHeaderItem( + gridState = gridState, + headers = remember(mediaState.value) { + mediaState.value.headers.toMutableStateList() + }, + mappedData = mappedData + ) - val item = firstHeaderIndex?.let(mappedData::getOrNull) - stickyHeaderLastItem.apply { - if (item != null && item is EncryptedMediaItem.Header) { - val newItem = item.text - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - val newIndex = (headers.indexOf(item) - 1).coerceAtLeast(0) - val previousHeader = headers[newIndex].text - .replace("Today", stringToday) - .replace("Yesterday", stringYesterday) - value = if (firstItem != null && !firstItem.key.isHeaderKey) { - previousHeader - } else { - newItem - } - } - }.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 }, @@ -287,25 +142,26 @@ fun PinchZoomGridScope.EncryptedMediaGridView( ) val density = LocalDensity.current - val searchBarOffset = remember(density, showSearchBar, searchBarPadding) { - with(density) { - return@with if (showSearchBar) - 28.sp.roundToPx() + searchBarPadding.roundToPx() else 0 - } - } - val headerMatcher: (LazyGridItemInfo) -> Boolean = remember { - { item -> item.key.isHeaderKey || item.key.isIgnoredKey } + val searchBarHeightPx = WindowInsets.statusBars.getTop(density) + val searchBarPaddingPx by remember(density, searchBarPadding) { + derivedStateOf { with(density) { searchBarPadding.roundToPx() } } } - val headerOffset by rememberHeaderOffset(gridState, headerMatcher, searchBarOffset) + StickyHeaderGrid( + state = gridState, modifier = Modifier.fillMaxSize(), - showSearchBar = showSearchBar, - headerOffset = headerOffset, + headerMatcher = { item -> item.key.isHeaderKey || item.key.isIgnoredKey }, + searchBarOffset = { if (showSearchBar) 28.roundSpToPx(density) + searchBarPaddingPx else 0 }, + toolbarOffset = { if (showSearchBar) 0 else 64.roundDpToPx(density) + searchBarHeightPx }, stickyHeader = { - val show = remember( - mediaState, + val show by remember( + mediaState.value, stickyHeaderItem - ) { mediaState.media.isNotEmpty() && stickyHeaderItem != null } + ) { + derivedStateOf { + mediaState.value.media.isNotEmpty() && stickyHeaderItem != null + } + } AnimatedVisibility( visible = show, enter = enterAnimation, @@ -336,8 +192,46 @@ fun PinchZoomGridScope.EncryptedMediaGridView( ) } } - ) { mediaGrid() } - } else mediaGrid() - + ) { + EncryptedMediaGrid( + gridState = gridState, + gridCells = gridCells, + mediaState = mediaState, + mappedData = mappedData, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + allowHeaders = allowHeaders, + aboveGridContent = aboveGridContent, + isScrolling = isScrolling, + emptyContent = emptyContent, + onMediaClick = onMediaClick + ) + } + } + AnimatedVisibility( + visible = !enableStickyHeaders + ) { + EncryptedMediaGrid( + gridState = gridState, + gridCells = gridCells, + mediaState = mediaState, + mappedData = mappedData, + paddingValues = paddingValues, + allowSelection = allowSelection, + selectionState = selectionState, + selectedMedia = selectedMedia, + toggleSelection = toggleSelection, + canScroll = canScroll, + allowHeaders = allowHeaders, + aboveGridContent = aboveGridContent, + isScrolling = isScrolling, + emptyContent = emptyContent, + onMediaClick = onMediaClick + ) + } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt index d68a181f8..07868abeb 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt @@ -1,217 +1,143 @@ package com.dot.gallery.feature_node.presentation.vault.components -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.boundsInParent -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex 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.feature_node.domain.model.EncryptedMediaItem -import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.math.abs +import com.dot.gallery.feature_node.presentation.util.getDate +import my.nanihadesuka.compose.InternalLazyVerticalGridScrollbar +import my.nanihadesuka.compose.ScrollbarLayoutSide +import my.nanihadesuka.compose.ScrollbarSelectionActionable +import my.nanihadesuka.compose.ScrollbarSelectionMode +import my.nanihadesuka.compose.ScrollbarSettings @Composable -fun BoxScope.EncryptedTimelineScroller( - gridState: LazyGridState, - mappedData: List, - paddingValues: PaddingValues -) { - val scope = rememberCoroutineScope() - val offsets = remember { mutableStateMapOf() } - var selectedIndex by remember { mutableIntStateOf(0) } - val feedbackManager = rememberFeedbackManager() - fun scrollIfNeeded(offset: Float) { - val index = offsets - .mapValues { abs(it.value - offset) } - .entries - .minByOrNull { it.value } - ?.key ?: return - if (selectedIndex == index) return - selectedIndex = index - scope.launch { - feedbackManager.vibrate() - gridState.scrollToItem(selectedIndex, scrollOffset = -250) - } - } - - var scrollVisible by remember { mutableStateOf(false) } - val scrollAlpha by animateFloatAsState( - animationSpec = tween(durationMillis = 1000), - targetValue = if (scrollVisible) 1f else 0f, - label = "scrollAlpha" - ) - - val configuration = LocalConfiguration.current - val height = remember(configuration) { configuration.screenHeightDp } - val headerList = - remember(mappedData) { mappedData.filterIsInstance() } - val bottomPadding = remember { 32 /*dp*/ } - - /** - * Distribute scroll items height proportionally to the amount of date headers - **/ - val parentTopPadding = remember(paddingValues) { paddingValues.calculateTopPadding().value } - val parentBottomPadding = remember { paddingValues.calculateBottomPadding().value } - val heightSize = - remember(height, parentTopPadding, parentBottomPadding, bottomPadding, headerList) { - (height - parentTopPadding - parentBottomPadding - bottomPadding) / headerList.size - } - - // W.I.P. - /*val yearList = remember { mutableStateListOf() } - LazyColumn( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(paddingValues) - .padding(top = 32.dp, bottom = bottomPadding.dp) - .padding(end = 48.dp) - .fillMaxHeight() - .wrapContentWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.End - ) { - itemsIndexed( - items = headerList, - key = { _, item -> item.key } - ) { index, item -> - val year = getYear(item.text) - if (!yearList.contains(year)) { - yearList.add(year) - val padding = heightSize * index - Log.d( - Constants.TAG, - "Height size: $heightSize, Index: $index, Top padding for $year: $padding" - ) - Text( - modifier = Modifier - .graphicsLayer { - translationY = -padding - } - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = RoundedCornerShape(100) - ) - .padding(vertical = 4.dp, horizontal = 5.dp), - text = year, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - }*/ - - Box( - modifier = Modifier - .padding(paddingValues) - .padding(top = 32.dp, bottom = bottomPadding.dp) - .graphicsLayer { - translationY = offsets[selectedIndex] ?: 0f - alpha = scrollAlpha - } - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.tertiary, - shape = RoundedCornerShape( - topStartPercent = 100, - bottomStartPercent = 100 - ) - ) - .align(Alignment.TopEnd) - .padding(vertical = 2.5.dp), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = R.drawable.ic_scroll_arrow), - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiary +fun rememberEncryptedScrollbarSettings( + headers: SnapshotStateList, +) : ScrollbarSettings { + val enabled by remember(headers) { derivedStateOf { headers.size > 3 } } + return remember(headers, enabled) { + ScrollbarSettings.Default.copy( + enabled = enabled, + side = ScrollbarLayoutSide.End, + selectionMode = ScrollbarSelectionMode.Full, + selectionActionable = ScrollbarSelectionActionable.Always, + scrollbarPadding = 0.dp, + thumbThickness = 24.dp, + thumbUnselectedColor = Color.Transparent, + thumbSelectedColor = Color.Transparent, + hideDisplacement = 0.dp ) } +} - Column( - modifier = Modifier - .padding(paddingValues) - .padding(top = 32.dp, bottom = bottomPadding.dp) - .align(Alignment.TopEnd) - .fillMaxHeight() - .verticalScroll(state = rememberScrollState()) - .graphicsLayer { - alpha = scrollAlpha - } - .pointerInput(Unit) { - detectVerticalDragGestures( - onDragStart = { - scope.launch { - scrollVisible = true - } - }, - onDragCancel = { - scope.launch { - delay(1500) - scrollVisible = false - } - }, - onDragEnd = { - scope.launch { - delay(500) - scrollVisible = false - } - }, - onVerticalDrag = { change, _ -> - if (!scrollVisible) { - scrollVisible = true +@Composable +fun EncryptedTimelineScroller( + state: LazyGridState, + modifier: Modifier = Modifier, + mappedData: SnapshotStateList, + headers: SnapshotStateList, + settings: ScrollbarSettings = rememberEncryptedScrollbarSettings(headers), + content: @Composable () -> Unit +) { + if (!settings.enabled) content() + else Box { + content() + InternalLazyVerticalGridScrollbar( + state = state, + settings = settings, + modifier = modifier, + indicatorContent = { index, isSelected -> + val stringToday = stringResource(R.string.header_today) + val stringYesterday = stringResource(R.string.header_yesterday) + val currentDate by remember(mappedData, index) { + derivedStateOf { + mappedData.getOrNull((index + 1).coerceAtMost(mappedData.size - 1))?.let { item -> + when (item) { + is EncryptedMediaItem.MediaViewItem -> item.media.timestamp.getDate( + stringToday = stringToday, + stringYesterday = stringYesterday + ) + is EncryptedMediaItem.Header -> item.text + } } - scrollIfNeeded(change.position.y) } + } + val isScrolling by remember(state) { derivedStateOf { state.isScrollInProgress } } + val offset by animateDpAsState( + targetValue = if (isScrolling || isSelected) 24.dp else 72.dp, label = "thumbOffset" ) - }, - verticalArrangement = Arrangement.Top - ) { - mappedData.forEachIndexed { i, header -> - if (header is EncryptedMediaItem.Header) { - Spacer( - modifier = Modifier - .width(16.dp) - .height(heightSize.dp) - .onGloballyPositioned { - offsets[i] = it.boundsInParent().center.y - } - ) + Row( + modifier = Modifier.offset { + IntOffset(offset.roundToPx(), 0) + }.zIndex(5f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + androidx.compose.animation.AnimatedVisibility( + visible = !currentDate.isNullOrEmpty() && isSelected, + enter = enterAnimation(250), + exit = exitAnimation(1000) + ) { + Text( + text = currentDate.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(100) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + Box( + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.tertiary, + shape = RoundedCornerShape( + topStartPercent = 100, + bottomStartPercent = 100 + ) + ) + .padding(vertical = 2.5.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_scroll_arrow), + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiary + ) + } + } } - } + ) } -} \ No newline at end of file +} 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 0d203f66a..f04ced23f 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 @@ -58,7 +58,7 @@ import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogA import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.RESTORE import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.TRASH import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager +import com.dot.gallery.feature_node.presentation.util.rememberFeedbackManager import com.dot.gallery.ui.theme.Shapes import com.github.panpf.sketch.AsyncImage import com.github.panpf.sketch.fetch.newBase64Uri 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 38d1e1802..2868bb6b2 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 @@ -13,6 +13,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -24,6 +26,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.dot.gallery.core.presentation.components.DragHandle import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.model.VaultState 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.AppBottomSheetState @@ -33,9 +36,12 @@ import kotlinx.coroutines.launch @Composable fun SelectVaultSheet( state: AppBottomSheetState, - vaults: List, + vaultState: VaultState, onVaultSelected: (Vault) -> Unit ) { + val vaults by remember(vaultState) { + derivedStateOf { vaultState.vaults } + } val scope = rememberCoroutineScope() val vaultOptions = remember(vaults, state) { vaults.map { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/rememberStickyHeaderItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/rememberStickyHeaderItem.kt new file mode 100644 index 000000000..0f44c3b70 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/rememberStickyHeaderItem.kt @@ -0,0 +1,58 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.res.stringResource +import com.dot.gallery.R +import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem +import com.dot.gallery.feature_node.domain.model.isHeaderKey + +@Composable +fun rememberEncryptedStickyHeaderItem( + gridState: LazyGridState, + headers: SnapshotStateList, + mappedData: SnapshotStateList +): State { + val stringToday = stringResource(id = R.string.header_today) + val stringYesterday = stringResource(id = R.string.header_yesterday) + + /** + * Remember last known header item + */ + val stickyHeaderLastItem = remember { mutableStateOf(null) } + + LaunchedEffect(gridState, headers, mappedData) { + snapshotFlow { gridState.layoutInfo.visibleItemsInfo } + .collect { visibleItems -> + val firstItem = visibleItems.firstOrNull() + val firstHeaderIndex = visibleItems.firstOrNull { + it.key.isHeaderKey && !it.key.toString().contains("big") + }?.index + + val item = firstHeaderIndex?.let(mappedData::getOrNull) + stickyHeaderLastItem.value = if (item != null && item is EncryptedMediaItem.Header) { + val newItem = item.text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + val newIndex = (headers.indexOf(item) - 1).coerceAtLeast(0) + val previousHeader = headers[newIndex].text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + if (firstItem != null && !firstItem.key.isHeaderKey) { + previousHeader + } else { + newItem + } + } else { + stickyHeaderLastItem.value + } + } + } + return stickyHeaderLastItem +} \ No newline at end of file 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 c6d0927f3..73d3fd1c2 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 @@ -5,31 +5,37 @@ package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview -import android.app.Activity -import android.view.WindowManager import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -38,29 +44,41 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.dot.gallery.core.Constants +import com.composables.core.BottomSheet +import com.composables.core.SheetDetent.Companion.FullyExpanded +import com.composables.core.rememberBottomSheetState import com.dot.gallery.core.Constants.Animation.enterAnimation import com.dot.gallery.core.Constants.Animation.exitAnimation import com.dot.gallery.core.Constants.DEFAULT_LOW_VELOCITY_SWIPE_DURATION +import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION 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.EncryptedMediaState import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.presentation.mediaview.components.MediaViewAppBar import com.dot.gallery.feature_node.presentation.mediaview.components.video.VideoPlayerController +import com.dot.gallery.feature_node.presentation.util.SecureWindow +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.BOTTOM_BAR_HEIGHT +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.BOTTOM_BAR_HEIGHT_SLIM +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.FullyExpanded +import com.dot.gallery.feature_node.presentation.util.ViewScreenConstants.ImageOnly 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.normalize import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController 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.EncryptedMediaViewActions +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.EncryptedMediaViewDetails import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.media.MediaPreviewComponent +import com.dot.gallery.ui.theme.BlackScrim import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -75,208 +93,344 @@ fun EncryptedMediaViewScreen( isStandalone: Boolean = false, mediaId: Long, mediaState: StateFlow, - vault: StateFlow, + currentVault: State, restoreMedia: (Vault, EncryptedMedia) -> Unit, deleteMedia: (Vault, EncryptedMedia) -> Unit ) { - val window = (LocalContext.current as Activity).window + SecureWindow { + val state by mediaState.collectAsStateWithLifecycle() - DisposableEffect(Unit) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - onDispose { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + var initialPage by rememberSaveable(mediaId, state) { + val lastMediaPosition = state.media.indexOfFirst { it.id == mediaId } + mutableIntStateOf(if (lastMediaPosition != -1) lastMediaPosition else 0) + } + val pagerState = rememberPagerState( + initialPage = initialPage, + initialPageOffsetFraction = 0f, + pageCount = state.media::size + ) + LaunchedEffect(Unit) { + snapshotFlow { state.isLoading } + .collectLatest { isLoading -> + if (!isLoading) { + initialPage = state.media.indexOfFirst { it.id == mediaId } + pagerState.scrollToPage(initialPage) + } + } } - } - var runtimeMediaId by rememberSaveable(mediaId) { mutableLongStateOf(mediaId) } - val state by mediaState.collectAsStateWithLifecycle() - val initialPage = rememberSaveable(runtimeMediaId) { - state.media.indexOfFirst { it.id == runtimeMediaId }.coerceAtLeast(0) - } - val pagerState = rememberPagerState( - initialPage = initialPage, - initialPageOffsetFraction = 0f, - pageCount = state.media::size - ) - val bottomSheetState = rememberAppBottomSheetState() - val currentDate = rememberSaveable { mutableStateOf("") } - val currentMedia = rememberSaveable { mutableStateOf(null) } - val currentVault by vault.collectAsStateWithLifecycle() + val currentDate = rememberSaveable { mutableStateOf("") } + val currentMedia = rememberSaveable { mutableStateOf(null) } + val scope = rememberCoroutineScope() - val showUI = rememberSaveable { mutableStateOf(true) } - val windowInsetsController = rememberWindowInsetsController() + val showUI = rememberSaveable { mutableStateOf(true) } + 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 scope = rememberCoroutineScope() + var lastIndex by remember { mutableIntStateOf(-1) } - LaunchedEffect(pagerState, state.media) { - snapshotFlow { pagerState.currentPage }.collect { page -> - updateContent(page) + BackHandler(!showUI.value) { + windowInsetsController.toggleSystemBars(show = true) + navigateUp() } - } + var sheetHeightDp by remember { mutableStateOf(0.dp) } + val sheetState = rememberBottomSheetState( + initialDetent = ImageOnly, + detents = listOf( + ImageOnly, + FullyExpanded { sheetHeightDp = it }) + ) - BackHandler(!showUI.value) { - windowInsetsController.toggleSystemBars(show = true) - navigateUp() - } + var normalizationTarget by remember { + mutableFloatStateOf(0f) + } + var isNormalizationTargetSet by remember { mutableStateOf(false) } + var lastPage by remember { mutableIntStateOf(pagerState.currentPage) } - Box( - modifier = Modifier - .background(Color.Black) - .fillMaxSize() - ) { - HorizontalPager( - modifier = Modifier - .pointerInput(Unit) { - detectVerticalDragGestures { change, dragAmount -> - if (dragAmount < -5) { - change.consume() - scope.launch { - bottomSheetState.show() - } - } - } - }, - state = pagerState, - flingBehavior = PagerDefaults.flingBehavior( - state = pagerState, - 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 - } else "empty" - }, - pageSpacing = 16.dp, - ) { index -> - var playWhenReady by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - snapshotFlow { pagerState.currentPage } - .collectLatest { currentPage -> - playWhenReady = currentPage == index - } + LaunchedEffect(state) { + snapshotFlow { pagerState.currentPage }.collectLatest { page -> + if (lastIndex != -1) { + val newIndex = lastIndex.coerceAtMost(pagerState.pageCount - 1) + pagerState.scrollToPage(newIndex) + lastIndex = -1 + } + if (page != lastPage) { + isNormalizationTargetSet = false + } + + currentMedia.value = state.media.getOrNull(page) + currentDate.value = currentMedia.value?.timestamp?.getDate(HEADER_DATE_FORMAT) ?: "" + + if (!state.isLoading && state.media.isEmpty() && !isStandalone) { + windowInsetsController.toggleSystemBars(show = true) + navigateUp() + } + lastPage = page } + } - MediaPreviewComponent( - media = state.media[index], - uiEnabled = showUI.value, - playWhenReady = playWhenReady, - onItemClick = { - showUI.value = !showUI.value - windowInsetsController.toggleSystemBars(showUI.value) + LaunchedEffect(isNormalizationTargetSet) { + snapshotFlow { sheetState.offset }.collectLatest { offset -> + if (offset < 1f && !isNormalizationTargetSet) { + isNormalizationTargetSet = true + normalizationTarget = offset } - ) { player, isPlaying, currentTime, totalTime, buffer, frameRate -> - Box( - modifier = Modifier.fillMaxSize() - ) { - val displayMetrics = LocalContext.current.resources.displayMetrics + } + } - //Width And Height Of Screen - val width = displayMetrics.widthPixels - Spacer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = width / 1.5f - } - .align(Alignment.TopEnd) - .clip(CircleShape) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onDoubleClick = { - scope.launch { - currentTime.value += 10 * 1000 - player.seekTo(currentTime.value) - delay(100) - player.play() - } - }, - onClick = { - showUI.value = !showUI.value - windowInsetsController.toggleSystemBars(showUI.value) - } - ) + val normalizedOffset by remember(normalizationTarget) { + derivedStateOf { + if (isNormalizationTargetSet) { + sheetState.offset.normalize(minValue = normalizationTarget) + } else 0f + } + } + val bottomPadding = remember(paddingValues) { + paddingValues.calculateBottomPadding() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationY = + -((sheetHeightDp - BOTTOM_BAR_HEIGHT - bottomPadding).toPx() * normalizedOffset) + }, + state = pagerState, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = tween( + easing = FastOutLinearInEasing, + durationMillis = DEFAULT_LOW_VELOCITY_SWIPE_DURATION ) + ), + key = { index -> + state.media.getOrNull(index) ?: "empty" + }, + pageSpacing = 16.dp, + ) { index -> + Column( + modifier = Modifier.fillMaxWidth() + ) { + var playWhenReady by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect { currentPage -> + playWhenReady = currentPage == index + } + } + val media by remember(index, mediaState) { + derivedStateOf { state.media.getOrNull(index) } + } - Spacer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = -width / 1.5f + AnimatedVisibility( + visible = remember(media) { media != null }, + enter = enterAnimation, + exit = exitAnimation + ) { + MediaPreviewComponent( + media = media!!, + uiEnabled = showUI.value, + playWhenReady = playWhenReady, + onSwipeDown = navigateUp, + onItemClick = { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars(showUI.value) } - .align(Alignment.TopStart) - .clip(CircleShape) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onDoubleClick = { - scope.launch { - currentTime.value -= 10 * 1000 - player.seekTo(currentTime.value) - delay(100) - player.play() - } - }, - onClick = { - showUI.value = !showUI.value - windowInsetsController.toggleSystemBars(showUI.value) + ) { player, isPlaying, currentTime, totalTime, buffer, frameRate -> + Box( + modifier = Modifier.fillMaxSize() + ) { + val context = LocalContext.current + val width = + remember(context) { context.resources.displayMetrics.widthPixels } + Spacer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = width / 1.5f + } + .align(Alignment.TopEnd) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onDoubleClick = { + scope.launch { + currentTime.longValue += 10 * 1000 + player.seekTo(currentTime.longValue) + delay(100) + player.play() + } + }, + onClick = { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars( + showUI.value + ) + } + ) + ) + + Spacer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = -width / 1.5f + } + .align(Alignment.TopStart) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onDoubleClick = { + scope.launch { + currentTime.longValue -= 10 * 1000 + player.seekTo(currentTime.longValue) + delay(100) + player.play() + } + }, + onClick = { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars( + showUI.value + ) + } + ) + ) + + androidx.compose.animation.AnimatedVisibility( + visible = showUI.value, + enter = enterAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), + exit = exitAnimation(DEFAULT_TOP_BAR_ANIMATION_DURATION), + modifier = Modifier + .fillMaxSize() + .zIndex(1f) + ) { + VideoPlayerController( + paddingValues = paddingValues, + player = player, + isPlaying = isPlaying, + currentTime = currentTime, + totalTime = totalTime, + buffer = buffer, + toggleRotate = toggleRotate, + frameRate = frameRate + ) } - ) - ) + } + } + } + } + } + LaunchedEffect(showUI.value) { + if (showUI.value && sheetState.currentDetent != ImageOnly) { + scope.launch { + sheetState.animateTo(ImageOnly) + } + } + } + MediaViewAppBar( + showUI = showUI.value, + showInfo = true, + showDate = remember(currentMedia.value) { + currentMedia.value?.timestamp != 0L + }, + currentDate = currentDate.value, + paddingValues = paddingValues, + onShowInfo = { + scope.launch { + sheetState.animateTo(FullyExpanded) + } + }, + onGoBack = { + scope.launch { + if (sheetState.currentDetent == FullyExpanded) { + sheetState.animateTo(ImageOnly) + } else { + navigateUp() + } + } + } + ) + BackHandler(sheetState.currentDetent == FullyExpanded) { + scope.launch { + sheetState.animateTo(ImageOnly) + } + } + val bottomSheetAlpha by animateFloatAsState( + targetValue = if (showUI.value) 1f else 0f, + label = "MediaViewActionsAlpha" + ) + BottomSheet( + state = sheetState, + enabled = showUI.value, + modifier = Modifier + .align(Alignment.BottomCenter) + .alpha(bottomSheetAlpha) + .fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val alpha by animateFloatAsState( + targetValue = 1f - normalizedOffset, + label = "MediaViewActions2Alpha" + ) AnimatedVisibility( - visible = showUI.value, - enter = enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), - exit = exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), - modifier = Modifier.fillMaxSize() + visible = remember(currentMedia.value) { + currentMedia.value != null + }, + enter = enterAnimation, + exit = exitAnimation ) { - VideoPlayerController( - paddingValues = paddingValues, - player = player, - isPlaying = isPlaying, - currentTime = currentTime, - totalTime = totalTime, - buffer = buffer, - toggleRotate = toggleRotate, - frameRate = frameRate - ) + Row( + modifier = Modifier + .graphicsLayer { + this.alpha = alpha + translationY = BOTTOM_BAR_HEIGHT_SLIM.toPx() * normalizedOffset + } + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, BlackScrim) + ) + ) + .padding( + top = 24.dp, + bottom = bottomPadding + ) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + EncryptedMediaViewActions( + currentIndex = pagerState.currentPage, + currentMedia = currentMedia.value!!, + currentVault = currentVault.value!!, + restoreMedia = restoreMedia, + deleteMedia = deleteMedia, + onDeleteMedia = { + lastIndex = it + } + ) + } } + + EncryptedMediaViewDetails( + currentMedia = currentMedia.value, + currentVault = currentVault.value, + restoreMedia = restoreMedia + ) } } } - EncryptedMediaViewAppBar( - showUI = showUI.value, - showDate = currentMedia.value?.timestamp != 0L, - currentDate = currentDate.value, - bottomSheetState = bottomSheetState, - paddingValues = paddingValues, - onGoBack = navigateUp - ) - EncryptedMediaViewBottomBar( - bottomSheetState = bottomSheetState, - paddingValues = paddingValues, - currentMedia = currentMedia.value, - currentVault = currentVault, - currentIndex = pagerState.currentPage, - deleteMedia = deleteMedia, - restoreMedia = restoreMedia, - onDeleteMedia = { - lastIndex = it - } - ) } - } \ No newline at end of file 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 33d7a8acf..81f8c0d58 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 @@ -5,46 +5,34 @@ package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components -import androidx.activity.compose.BackHandler 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 import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Share -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector @@ -55,204 +43,105 @@ import androidx.compose.ui.text.intl.Locale 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 com.dot.gallery.R -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.core.presentation.components.DragHandle +import com.dot.gallery.core.presentation.components.NavigationBarSpacer import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.model.rememberMediaDateCaption +import com.dot.gallery.feature_node.presentation.mediaview.components.DateHeader +import com.dot.gallery.feature_node.presentation.mediaview.components.MediaInfoChip2 import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction -import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState -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.shareMedia import com.dot.gallery.feature_node.presentation.vault.components.EncryptedTrashDialog -import com.dot.gallery.ui.theme.BlackScrim -import com.dot.gallery.ui.theme.Shapes import kotlinx.coroutines.launch @Composable -fun BoxScope.EncryptedMediaViewBottomBar( - bottomSheetState: AppBottomSheetState, - paddingValues: PaddingValues, +fun EncryptedMediaViewDetails( currentMedia: EncryptedMedia?, currentVault: Vault?, - currentIndex: Int = 0, - onDeleteMedia: ((Int) -> Unit)? = null, - restoreMedia: (Vault, EncryptedMedia) -> Unit, - deleteMedia: (Vault, EncryptedMedia) -> Unit + restoreMedia: (Vault, EncryptedMedia) -> Unit ) { - Row( + Column( modifier = Modifier - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, BlackScrim) + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp ) ) - .padding( - top = 24.dp, - bottom = paddingValues.calculateBottomPadding() + .background( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .align(Alignment.BottomCenter), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly, ) { - currentMedia?.let { - EncryptedMediaViewActions( - currentIndex = currentIndex, - currentMedia = it, - currentVault = currentVault!!, - onDeleteMedia = onDeleteMedia, - restoreMedia = restoreMedia, - deleteMedia = deleteMedia - ) - } - } - currentMedia?.let { - EncryptedMediaInfoBottomSheet( - media = it, - currentVault = currentVault!!, - restoreMedia = restoreMedia, - state = bottomSheetState - ) - } -} -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EncryptedMediaInfoBottomSheet( - media: EncryptedMedia, - currentVault: Vault?, - restoreMedia: (Vault, EncryptedMedia) -> Unit, - state: AppBottomSheetState -) { - val scope = rememberCoroutineScope() - if (state.isVisible) { - ModalBottomSheet( - onDismissRequest = { - scope.launch { - state.hide() - } - }, - dragHandle = { DragHandle() }, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetState = state.sheetState, - contentWindowInsets = { WindowInsets(0, 0, 0, 0) } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - BackHandler { - scope.launch { - state.hide() - } - } - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - ) { - EncryptedMediaViewInfoActions(media, restoreMedia, currentVault!!) - Spacer(modifier = Modifier.height(8.dp)) - EncryptedMediaInfoDateCaptionContainer(media) - Spacer(modifier = Modifier.height(8.dp)) - Spacer(modifier = Modifier.navigationBarsPadding()) - } + DragHandle() } - } -} -@Composable -fun EncryptedMediaInfoDateCaptionContainer( - media: EncryptedMedia -) { - Column { - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = Shapes.large - ) - .padding(vertical = 16.dp) - .padding(start = 16.dp, end = 12.dp), + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier.fillMaxWidth(), + visible = currentMedia != null, + enter = enterAnimation, + exit = exitAnimation ) { - Column( - modifier = Modifier - .align(Alignment.TopStart) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = media.timestamp.getDate(Constants.EXIF_DATE_FORMAT), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp - ) - val defaultDesc = stringResource(R.string.image_add_description) - SelectionContainer { - Text( - text = defaultDesc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column { + val dateCaption = rememberMediaDateCaption(null, currentMedia!!) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + item { + DateHeader( + modifier = Modifier.fillMaxWidth(), + mediaDateCaption = dateCaption + ) + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(state = rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (currentMedia.isRaw) { + MediaInfoChip2( + text = currentMedia.fileExtension.toUpperCase(Locale.current), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + item { + EncryptedMediaViewInfoActions( + media = currentMedia, + restoreMedia = restoreMedia, + currentVault = currentVault!! + ) + } + item { + NavigationBarSpacer() + } } } } - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(state = rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (media.isRaw) { - EncryptedMediaInfoChip( - text = media.fileExtension.toUpperCase(Locale.current), - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - } } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun EncryptedMediaInfoChip( - text: String, - contentColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, - containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, - outlineInLightTheme: Boolean = true, - onClick: () -> Unit = {}, - onLongClick: () -> Unit = {}, -) { - Text( - modifier = Modifier - .background( - color = containerColor, - shape = Shapes.extraLarge - ) - .then( - if (!isSystemInDarkTheme() && outlineInLightTheme) Modifier.border( - width = 0.5.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - shape = Shapes.extraLarge - ) else Modifier - ) - .clip(Shapes.extraLarge) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) - .padding(horizontal = 16.dp, vertical = 8.dp), - text = text, - style = MaterialTheme.typography.bodyMedium, - color = contentColor - ) -} - @Composable private fun EncryptedMediaViewInfoActions( media: EncryptedMedia, @@ -276,7 +165,7 @@ private fun EncryptedMediaViewInfoActions( } @Composable -private fun EncryptedMediaViewActions( +fun EncryptedMediaViewActions( currentIndex: Int, currentMedia: EncryptedMedia, currentVault: Vault, 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 8d111e379..e787dff57 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 @@ -21,6 +21,7 @@ fun MediaPreviewComponent( uiEnabled: Boolean, playWhenReady: Boolean, onItemClick: () -> Unit, + onSwipeDown: () -> Unit, videoController: @Composable (ExoPlayer, MutableState, MutableLongState, Long, Int, Float) -> Unit, ) { Box( @@ -38,7 +39,8 @@ fun MediaPreviewComponent( ZoomablePagerImage( media = media, uiEnabled = uiEnabled, - onItemClick = onItemClick + onItemClick = onItemClick, + onSwipeDown = onSwipeDown ) } } 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 0fa76a836..85972f592 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur @@ -20,11 +21,14 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION -import com.dot.gallery.core.Settings +import com.dot.gallery.core.Settings.Misc.rememberAllowBlur import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus +import com.dot.gallery.core.presentation.components.util.swipe import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.github.panpf.sketch.cache.CachePolicy import com.github.panpf.sketch.fetch.newBase64Uri +import com.github.panpf.sketch.rememberAsyncImagePainter import com.github.panpf.sketch.request.ComposableImageRequest import com.github.panpf.zoomimage.SketchZoomAsyncImage @@ -33,11 +37,19 @@ fun ZoomablePagerImage( modifier: Modifier = Modifier, media: EncryptedMedia, uiEnabled: Boolean, - onItemClick: () -> Unit + onItemClick: () -> Unit, + onSwipeDown: () -> Unit ) { - val painter = com.github.panpf.sketch.rememberAsyncImagePainter( - request = ComposableImageRequest(newBase64Uri(mimeType = media.mimeType, imageData = media.bytes)) { - memoryCachePolicy(com.github.panpf.sketch.cache.CachePolicy.ENABLED) + val painter = rememberAsyncImagePainter( + request = ComposableImageRequest( + remember(media) { + newBase64Uri( + mimeType = media.mimeType, + imageData = media.bytes + ) + } + ) { + memoryCachePolicy(CachePolicy.ENABLED) crossfade() }, contentScale = ContentScale.Fit, @@ -46,7 +58,7 @@ fun ZoomablePagerImage( Box(modifier = Modifier.fillMaxSize()) { ProvideBatteryStatus { - val allowBlur by Settings.Misc.rememberAllowBlur() + val allowBlur by rememberAllowBlur() val isPowerSavingMode = LocalBatteryStatus.current.isPowerSavingMode if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && allowBlur && !isPowerSavingMode) { val blurAlpha by animateFloatAsState( @@ -67,9 +79,16 @@ fun ZoomablePagerImage( } SketchZoomAsyncImage( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .swipe( + onSwipeUp = null, + onSwipeDown = onSwipeDown + ), onTap = { onItemClick() }, - uri = newBase64Uri(mimeType = media.mimeType, imageData = media.bytes), + uri = remember(media) { + 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 0241d4387..dc6fa8248 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 @@ -6,6 +6,7 @@ package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video import android.annotation.SuppressLint +import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri import android.view.ViewGroup.LayoutParams.MATCH_PARENT @@ -30,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -40,19 +42,13 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import com.dot.gallery.BuildConfig import com.dot.gallery.feature_node.domain.model.EncryptedMedia import kotlinx.coroutines.delay -import java.io.ByteArrayInputStream import java.io.File import java.io.FileDescriptor import java.io.FileInputStream import java.io.FileOutputStream -import java.io.InputStream -import java.net.MalformedURLException -import java.net.URISyntaxException -import java.net.URL -import java.net.URLConnection -import java.net.URLStreamHandler import kotlin.time.Duration.Companion.seconds @@ -71,33 +67,19 @@ fun encryptedMediaToFileDescriptor(encryptedMedia: EncryptedMedia): FileDescript } object UriByteDataHelper { - fun getUri(data: ByteArray?, isVideo: Boolean): Uri { - try { - val url = URL(null, "bytes:///" + if (isVideo) "video" else "image", BytesHandler(data)) - return Uri.parse(url.toURI().toString()) - } catch (e: MalformedURLException) { - throw RuntimeException(e) - } catch (e: URISyntaxException) { - throw RuntimeException(e) + fun getUri(context: Context, data: ByteArray, extension: String, isVideo: Boolean): Uri { + // Create a temporary file + val tempFile = + File(context.cacheDir, "shared_${if (isVideo) "video" else "image"}.${extension}") + + // Write the ByteArray to the temporary file + FileOutputStream(tempFile).use { fileOutputStream -> + fileOutputStream.write(data) + fileOutputStream.flush() } - } - private class BytesHandler(private val data: ByteArray?) : URLStreamHandler() { - override fun openConnection(url: URL): URLConnection { - return object : URLConnection(url) { - override fun connect() {} - override fun getInputStream(): InputStream { - return ByteArrayInputStream(data) - } - } - } - } - - private class ByteUrlConnection(url: URL, private val data: ByteArray?) : URLConnection(url) { - override fun connect() {} - override fun getInputStream(): InputStream { - return ByteArrayInputStream(data) - } + // Get the URI of the temporary file using FileProvider + return FileProvider.getUriForFile(context, BuildConfig.CONTENT_AUTHORITY, tempFile) } } @@ -134,7 +116,16 @@ fun VideoPlayer( .build().apply { videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT repeatMode = Player.REPEAT_MODE_ONE - setMediaItem(MediaItem.fromUri(UriByteDataHelper.getUri(media.bytes, true))) + setMediaItem( + MediaItem.fromUri( + UriByteDataHelper.getUri( + context = context, + data = media.bytes, + extension = media.fileExtension, + isVideo = true + ) + ) + ) prepare() } } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt index 4e2481ccc..028f6f70b 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt @@ -5,7 +5,10 @@ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.PromptInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat @@ -56,18 +59,22 @@ fun rememberBiometricPrompt(biometricPromptCallback: BiometricPrompt.Authenticat @Composable fun rememberBiometricState( - biometricPromptInfo: BiometricPrompt.PromptInfo, + title: String, + subtitle: String, onSuccess: () -> Unit, onFailed: () -> Unit ): BiometricState { val biometricManager = rememberBiometricManager() val callback = rememberBiometricCallback(onSuccess, onFailed) val prompt = rememberBiometricPrompt(callback) - val promptInfo = remember { biometricPromptInfo } - return remember(biometricManager, biometricPromptInfo) { + return remember(biometricManager, title, subtitle) { BiometricState( biometricManager = biometricManager, - promptInfo = promptInfo, + promptInfo = PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .setTitle(title) + .setSubtitle(subtitle) + .build(), prompt = prompt ) } @@ -75,13 +82,15 @@ fun rememberBiometricState( class BiometricState( biometricManager: BiometricManager, - private val promptInfo: BiometricPrompt.PromptInfo, + private val promptInfo: PromptInfo, private val prompt: BiometricPrompt ) { - val canAllowAccess = biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS + val isSupported by mutableStateOf( + biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS + ) fun authenticate() { - if (canAllowAccess) { + if (isSupported) { prompt.authenticate(promptInfo) } } 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 47ecd672a..e0a07577e 100644 --- a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt +++ b/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt @@ -9,12 +9,12 @@ import android.app.Application import android.content.ContentResolver import android.content.Context import androidx.room.Room +import androidx.work.WorkManager 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 import com.dot.gallery.feature_node.domain.repository.MediaRepository -import com.dot.gallery.feature_node.domain.use_case.MediaUseCases -import com.dot.gallery.feature_node.domain.use_case.VaultUseCases +import com.dot.gallery.feature_node.domain.use_case.MediaHandleUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -46,24 +46,25 @@ object AppModule { @Provides @Singleton - fun provideMediaUseCases(repository: MediaRepository, @ApplicationContext context: Context): MediaUseCases { - return MediaUseCases(context, repository) + fun provideMediaHandleUseCase(repository: MediaRepository, @ApplicationContext context: Context): MediaHandleUseCase { + return MediaHandleUseCase(repository, context) } @Provides @Singleton - fun provideVaultUseCases(repository: MediaRepository): VaultUseCases { - return VaultUseCases(repository) + fun provideWorkManager(@ApplicationContext context: Context): WorkManager { + return WorkManager.getInstance(context) } @Provides @Singleton fun provideMediaRepository( @ApplicationContext context: Context, + workManager: WorkManager, database: InternalDatabase, keychainHolder: KeychainHolder ): MediaRepository { - return MediaRepositoryImpl(context, database, keychainHolder) + return MediaRepositoryImpl(context, workManager, database, keychainHolder) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de6463cb5..0993b7d57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -250,6 +250,15 @@ Hide navigation bar on scroll Automatically hide the navigation bar while scrolling Navigation + Setup your vault + + Vault Name + Please set-up a phone security measure before setting up the vault. + The vault will be accessed using your phone security measures (password or biometrics)\n\nEncryption key can be accessed inside the vault and will be used in order to restore any vaults. + Vault \"%1$s\" already exists + Ignored + Location + Update file name %s item %s items diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml index b4e22f7a0..467864162 100644 --- a/app/src/main/res/xml/filepaths.xml +++ b/app/src/main/res/xml/filepaths.xml @@ -1,6 +1,4 @@ - - + + \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/30033.txt b/fastlane/metadata/android/en-US/changelogs/30033.txt new file mode 100644 index 000000000..6d6cb8a4e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30033.txt @@ -0,0 +1,26 @@ +Added + Vault - A secure way to hide and encrypt your photos + Ignored Albums - A safe and easy way to ignore certain albums or automatically ignore a bunch of them with Regex + Option to Move the album from trash from Album screen / Long press the album + Option to not hide the searchbar and/or the navigationbar while scrolling + New gesture: Drag down to close media viewer + New UI for Setup Wizard +Improved + UI Performance while viewing media grid + Media Content parsing and updating + MediaView screen - New UI and animations + Scroll Thumb + Slightly updated UI Design on Library tab + Empty and loading animations for media and albums + Detect initial grid size for bigger screens + Album sorting toggle + Re-arranged Settings screen +Fixed + Lags and hangs while fast scrolling the media grid + Major lags while scrolling and updating the Media Store (e.g., when new images are being added to your gallery, and you want to scroll the grid) + Small fixes to the video player +Changed + Moved to a new image loading library (sketch) + Moved to a new image subsampling library (com.github.panpf.zoomimage) +Removed + Temporarily removed the Image Editor - a new one will be coming soon \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index e2b53ec20..9ada993e9 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png index a09c1e6d9..a3fa4e478 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/10.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/11.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/11.png index 1db273ede..06c08df0b 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/11.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/11.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/12.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/12.png index c4091cdac..9f11a95ce 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/12.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/12.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/13.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/13.png index 179f81b5b..2619ec43d 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/13.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/13.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index a6d5b2faf..530953201 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index be01ac8f9..63bc33469 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index 85b8ae839..9b67f5e01 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index a387899b8..9830ebcf4 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index e3c67bd32..7249ee75f 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png index d0833837d..ee8c44224 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png index 0d7a4a92f..21c142cac 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png index 2a8978d5d..be2a32ebc 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/9.png differ diff --git a/gradle.properties b/gradle.properties index 6a64d4774..480453a02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,5 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -org.gradle.unsafe.configuration-cache=true \ No newline at end of file +org.gradle.unsafe.configuration-cache=true +android.suppressUnsupportedCompileSdk=35 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e94f7b469..0044bc4b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,31 +1,33 @@ [versions] -agp = "8.3.2" +adaptive = "1.1.0-alpha01" +agp = "8.5.2" avifCoderCoil = "1.8.0" -benchmarkMacroJunit4 = "1.2.4" +benchmarkMacroJunit4 = "1.3.0" biometric = "1.2.0-alpha05" appcompat = "1.7.0" composeVideo = "1.2.0" -core = "1.8.0" +core = "1.11.0" coreSplashscreen = "1.0.1" fuzzywuzzy = "1.4.0" exifinterface = "1.3.7" gpuimage = "2.1.0" -graphicsShapes = "1.0.0-rc01" +graphicsShapes = "1.0.0" jxlCoder = "2.1.10" -kotlin = "2.0.0" +kotlin = "2.0.20" kotlinCoroutinesVersion = "1.8.1" -ksp = "2.0.0-1.0.23" +ksp = "2.0.20-1.0.24" core-ktx = "1.13.1" junit = "4.13.2" androidx-test-ext-junit = "1.2.1" espresso-core = "3.6.1" +lazycolumnscrollbar = "2.2.0" lifecycle-runtime = "2.8.4" activity-compose = "1.9.1" -compose-bom = "2024.06.00" hilt = "2.51" +hiltCommon = "1.2.0" material = "1.12.0" -material3 = "1.3.0-beta05" -media3Exoplayer = "1.4.0" +material3 = "1.3.0-rc01" +media3Exoplayer = "1.4.1" navigation-runtime-ktx = "2.7.7" pinchzoomgrid = "0.0.5" profileinstaller = "1.3.1" @@ -35,12 +37,16 @@ datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" sketch = "4.0.0-alpha06" uiautomator = "2.3.0" -composealpha = "1.7.0-beta07" +composealpha = "1.7.0-rc01" kotlinxSerializationJson = "1.7.0" +workRuntimeKtx = "2.9.1" zoomimageViewSketch = "1.1.0-alpha05" [libraries] # AndroidX +androidx-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } +androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" } +androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" } androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -60,21 +66,21 @@ androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstall # Coil androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } avif-coder-coil = { module = "com.github.awxkee:avif-coder-coil", version.ref = "avifCoderCoil" } # Compose -compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } -compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } -compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "composealpha" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "composealpha" } compose-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-runtime" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version = "composealpha" } compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } compose-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3" } -compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -compose-ui-tooling-preview = { group = "androidx.compose.ui", name= "ui-tooling-preview" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "composealpha" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name= "ui-tooling-preview", version.ref = "composealpha" } compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "composealpha" } -compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "composealpha" } compose-video = { module = "io.sanghun:compose-video", version.ref = "composeVideo" } # Compose-shimmer @@ -87,12 +93,15 @@ compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-mani 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" } -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } +androidx-hilt-common = { group = "androidx.hilt", name = "hilt-common", version.ref = "hiltCommon" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltCommon" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltCommon" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltCommon" } # Room gpuimage = { module = "jp.co.cyberagent.android:gpuimage", version.ref = "gpuimage" } jxl-coder-coil = { module = "com.github.awxkee:jxl-coder-coil", version.ref = "jxlCoder" } +lazycolumnscrollbar = { module = "com.github.nanihadesuka:LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } pinchzoomgrid = { module = "io.github.dokar3:pinchzoomgrid", version.ref = "pinchzoomgrid" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } diff --git a/libs/cropper/build.gradle.kts b/libs/cropper/build.gradle.kts index ded5e6546..0ee84689e 100644 --- a/libs/cropper/build.gradle.kts +++ b/libs/cropper/build.gradle.kts @@ -40,7 +40,6 @@ android { } dependencies { - implementation(platform(libs.compose.bom)) implementation(libs.compose.foundation) implementation(libs.compose.ui) implementation(libs.compose.runtime) diff --git a/libs/gesture/build.gradle.kts b/libs/gesture/build.gradle.kts index 319fa0786..0c23eb9c7 100644 --- a/libs/gesture/build.gradle.kts +++ b/libs/gesture/build.gradle.kts @@ -40,7 +40,6 @@ android { } dependencies { - implementation(platform(libs.compose.bom)) implementation(libs.compose.foundation) implementation(libs.compose.ui) implementation(libs.compose.runtime) diff --git a/screenshots/preview.png b/screenshots/preview.png index f17cb9787..17c412f0a 100644 Binary files a/screenshots/preview.png and b/screenshots/preview.png differ